diff --git a/apps/web/src/router/index.spec.ts b/apps/web/src/router/index.spec.ts index ea56ece..43b42e4 100644 --- a/apps/web/src/router/index.spec.ts +++ b/apps/web/src/router/index.spec.ts @@ -31,12 +31,13 @@ describe('router layout fork', () => { expect(router.resolve('/first-run-setup').meta.public).toBe(true) }) - it('renders /access/identities nested under the shell, behind the auth gate', () => { + it('renders /access/identities nested under the shell, behind the auth + PDP gates', () => { const m = router.resolve('/access/identities') expect(m.name).toBe('access-identities') expect(m.matched.length).toBeGreaterThanOrEqual(2) expect(m.meta.requiresAuth).toBe(true) - // PR-09 is read-only behind auth; the PDP requiredAction gate arrives in PR-10. - expect(m.meta.requiredAction).toBeUndefined() + // PDP fail-closed gate (read on identity) — guards.ts denies until the PDP backend allows. + expect(m.meta.requiredAction).toBe('read') + expect(m.meta.requiredResource).toBe('identity') }) }) diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 6682e06..b211406 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -30,13 +30,14 @@ const routes: RouteRecordRaw[] = [ meta: { requiresAuth: false }, }, { - // Access · Identities (Batch 2). PR-09 ships the read-only list behind - // the auth gate; PR-10 adds meta.requiredAction (PDP fail-closed gate) - // once the operation modals + land. Own async chunk via subpath. + // Access · Identities (Batch 2). Behind the auth gate + the PDP gate: + // `requiredAction` makes the route fail-closed (guards.ts redirects home + // until the PDP backend allows `read` on `identity`; BR-004 stub denies + // until /access/decide lands). Own async chunk via the subpath export. path: 'access/identities', name: 'access-identities', component: () => import('@gocell/access/views/identities'), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, requiredAction: 'read', requiredResource: 'identity' }, }, ], }, diff --git a/packages/access/README.md b/packages/access/README.md index 1c259b4..2cfcd87 100644 --- a/packages/access/README.md +++ b/packages/access/README.md @@ -15,7 +15,11 @@ auth store(全内存 token)+ first-run / login 视图 + Identities 列表 + | `./views/identities` (`src/views/IdentitiesView.vue`) | `IdentitiesView`(`/access/identities` 列表页,路由懒加载) | > 每个 view 各为独立 export 子路径 → 各自独立 async chunk(访问 `/login` 不连带加载 first-run 向导 / identities 表)。 -> `api/setup`、`api/identities`、`composables/useSetupWizard`、`lib/validation`、`components/IdentityStatusPill.vue` 是包内私有模块(不在 `exports`),仅供本包 views / store 消费。 +> `api/setup`、`api/identities`、`composables/useSetupWizard`、`lib/validation`、`lib/identityValidation`、 +> `components/{IdentityStatusPill,IdentityFormModal,ChangePasswordModal,ConfirmDialog}.vue` +> 是包内私有模块(不在 `exports`),仅供本包 views / store 消费。 +> a11y modal 原语 `ModalShell`(focus-trap + ESC + backdrop + 背景 inert + 焦点回归)已归属 +> `@gocell/core/components`,三个 modal 经 `import { ModalShell } from '@gocell/core/components'` 消费。 ## 依赖的 contract @@ -26,6 +30,9 @@ auth store(全内存 token)+ first-run / login 视图 + Identities 列表 + | `HttpAuthSetupStatusV1Response` | `api/setup.fetchSetupStatus()` first-run 门控 | | `HttpAuthSetupAdminV1Request/Response` | `api/setup.createAdmin()` 首位 admin 创建 | | `HttpAuthUserGetV1Response`(`['data']`) | `api/identities` 的 `Identity` 行类型(list 行字段复用 get 契约) | +| `HttpAuthUserCreateV1Request` | `api/identities.createUser()`(POST `/users`) | +| `HttpAuthUserPatchV1Request` | `api/identities.patchUser()` 编辑(PATCH `/users/{id}`,email/status/requirePasswordReset;PATCH 是 email-only PUT `update` 的超集,故 UI 不另用 update) | +| `HttpAuthUserChangePasswordV1Request` | `api/identities.changeUserPassword()`(POST `/users/{id}/password`,old+new) | > **BR-005 pending**:`http.auth.user.list` 后端尚未交付(`/users` 路由仅 8 handler,无 list)。 > `api/identities.ts` 的 `UserListPage` 信封(`{ data, nextCursor, hasMore }`,cursor 分页,对齐 @@ -58,8 +65,9 @@ contract 来源:`@gocell/contracts`(codegen 派生,只读)。 - **store state**:`users`、`loading`、`errorKey`、`nextCursor`、`hasMore`、`filter`(client-side quick-filter) - **store getter**:`filteredUsers`(按 username / email 子串过滤当前已加载页) -- **store actions**:`fetchList()`(首页,replace)、`loadMore()`(cursor 续页,append;无下页或在途时 no-op);错误经 `toI18nKey` 落 `errorKey`,不抛中文字面量 -- **`IdentitiesView`**:`AppShell` 内子路由 `/access/identities`;hand-rolled 语义 `` + status pill + 客户端筛选 + 禁用「服务账号」tab 占位(FR-030,`aria-disabled` + `tabindex="-1"`)。MVP 只读;行操作 modal + `` 见 PR-10。 +- **store read actions**:`fetchList()`(首页,replace)、`loadMore()`(cursor 续页,append;无下页或在途时 no-op);错误经 `toI18nKey` 落 `errorKey`,不抛中文字面量 +- **store mutation actions**:`create` / `edit` / `lock` / `unlock` / `remove` / `changePassword`。**与读操作相反,mutation 失败时 re-throw**(由触发的 modal 内联展示并保持打开);成功后 `await fetchList()` 以列表为真相源(`changePassword` 不 refetch,行可见字段不变)。 +- **`IdentitiesView`**:`AppShell` 内子路由 `/access/identities`;hand-rolled 语义 `
` + status pill + 客户端筛选 + 禁用「服务账号」tab 占位(FR-030,`aria-disabled` + `tabindex="-1"`)。行操作(create/edit/change-password/lock/unlock/delete)开 modal,每个动作按钮挂 ``(fail-closed:PDP 不允许即隐藏);路由另挂 `meta.requiredAction='read'` + `requiredResource='identity'`(guards.ts fail-closed,PDP 后端未接通前整页拒绝,见 BR-004)。 - **BR-005**:list 端点未交付,`api/identities` 用临时信封类型(见上「依赖的 contract」)。 ### `createPdpClient(): PdpClient` @@ -81,4 +89,4 @@ pnpm -F @gocell/access test pnpm -F @gocell/access typecheck ``` -覆盖:`useAuthStore`(含 login/logout)、`createPdpClient`(fail-closed + cache + TTL)、`api/setup`、`api/identities`、`useIdentitiesStore`、`IdentityStatusPill`、`IdentitiesView`、`lib/validation`、`useSetupWizard`、`LoginView`、`FirstRunSetupView`。整包 ≥ 80%(实测 ~97% lines)。 +覆盖:`useAuthStore`(含 login/logout)、`createPdpClient`(fail-closed + cache + TTL)、`api/setup`、`api/identities`(list + mutations)、`useIdentitiesStore`(读 + 写 + refetch)、`lib/validation`、`lib/identityValidation`、`IdentityStatusPill`、`ModalShell`(focus-trap)、`IdentityFormModal`、`ChangePasswordModal`、`ConfirmDialog`、`IdentitiesView`(含 `` fail-closed)、`useSetupWizard`、`LoginView`、`FirstRunSetupView`。整包 ≥ 80%(实测 ~97.5% lines)。 diff --git a/packages/access/src/api/identities.spec.ts b/packages/access/src/api/identities.spec.ts index 676c1db..60e2e31 100644 --- a/packages/access/src/api/identities.spec.ts +++ b/packages/access/src/api/identities.spec.ts @@ -1,7 +1,16 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import MockAdapter from 'axios-mock-adapter' import { http } from '@gocell/request' -import { listUsers, USERS_URL } from './identities' +import { + listUsers, + createUser, + patchUser, + deleteUser, + lockUser, + unlockUser, + changeUserPassword, + USERS_URL, +} from './identities' const page = { data: [ @@ -60,3 +69,70 @@ describe('identities api · listUsers', () => { await expect(listUsers()).rejects.toThrow() }) }) + +describe('identities api · mutations', () => { + let mock: MockAdapter + + beforeEach(() => { + mock = new MockAdapter(http) + }) + + afterEach(() => { + mock.restore() + }) + + it('createUser POSTs the body to the users collection', async () => { + const body = { username: 'bob', email: 'bob@corp.example', password: 'Secret!23' } + mock.onPost(USERS_URL).reply(201, { data: { id: 'u-2', ...body } }) + await createUser(body) + expect(mock.history.post).toHaveLength(1) + expect(mock.history.post[0]?.url).toBe(USERS_URL) + expect(JSON.parse(mock.history.post[0]?.data as string)).toEqual(body) + }) + + it('patchUser PATCHes the item endpoint with the partial body', async () => { + mock.onPatch(`${USERS_URL}/u-1`).reply(200, { data: {} }) + await patchUser('u-1', { email: 'new@corp.example' }) + expect(mock.history.patch[0]?.url).toBe(`${USERS_URL}/u-1`) + expect(JSON.parse(mock.history.patch[0]?.data as string)).toEqual({ email: 'new@corp.example' }) + }) + + it('deleteUser DELETEs the item endpoint', async () => { + mock.onDelete(`${USERS_URL}/u-1`).reply(204) + await deleteUser('u-1') + expect(mock.history.delete[0]?.url).toBe(`${USERS_URL}/u-1`) + }) + + it('lockUser POSTs the lock sub-resource', async () => { + mock.onPost(`${USERS_URL}/u-1/lock`).reply(200, { data: { status: 'locked' } }) + await lockUser('u-1') + expect(mock.history.post[0]?.url).toBe(`${USERS_URL}/u-1/lock`) + }) + + it('unlockUser POSTs the unlock sub-resource', async () => { + mock.onPost(`${USERS_URL}/u-1/unlock`).reply(200) + await unlockUser('u-1') + expect(mock.history.post[0]?.url).toBe(`${USERS_URL}/u-1/unlock`) + }) + + it('changeUserPassword POSTs old+new to the password sub-resource', async () => { + const body = { oldPassword: 'old', newPassword: 'New!2345' } + mock.onPost(`${USERS_URL}/u-1/password`).reply(200, { data: {} }) + await changeUserPassword('u-1', body) + expect(mock.history.post[0]?.url).toBe(`${USERS_URL}/u-1/password`) + expect(JSON.parse(mock.history.post[0]?.data as string)).toEqual(body) + }) + + it('percent-encodes the id in the item URL', async () => { + mock.onDelete(`${USERS_URL}/a%2Fb`).reply(204) + await deleteUser('a/b') + expect(mock.history.delete[0]?.url).toBe(`${USERS_URL}/a%2Fb`) + }) + + it('propagates the rejection on a failed mutation', async () => { + mock.onPost(USERS_URL).networkError() + await expect( + createUser({ username: 'x', email: 'x@y.z', password: 'Secret!23' }), + ).rejects.toThrow() + }) +}) diff --git a/packages/access/src/api/identities.ts b/packages/access/src/api/identities.ts index c630cae..0a88df2 100644 --- a/packages/access/src/api/identities.ts +++ b/packages/access/src/api/identities.ts @@ -1,15 +1,25 @@ /** * identities.ts — identity-management API (accesscore `identitymanage` slice). * - * MVP scope: list users (`type=user`). Create/edit/lock/unlock/change-password - * wrappers land with the operation modals (PR-10). + * MVP scope: `type=user`. List (BR-005 provisional envelope) + the mutation + * surface backing the operation modals: create / edit (PATCH) / lock / unlock / + * change-password / delete. Edits go through PATCH (a superset of the email-only + * PUT `update`), so no separate `update` wrapper is needed by the UI. */ import { http } from '@gocell/request' -import type { HttpAuthUserGetV1Response } from '@gocell/contracts' +import type { + HttpAuthUserGetV1Response, + HttpAuthUserCreateV1Request, + HttpAuthUserPatchV1Request, + HttpAuthUserChangePasswordV1Request, +} from '@gocell/contracts' /** Collection endpoint for user identities (shared by list + create). */ export const USERS_URL = '/api/v1/access/users' +/** Item endpoint for a single identity (edit / delete / lock / unlock / password). */ +const userUrl = (id: string): string => `${USERS_URL}/${encodeURIComponent(id)}` + /** * A single identity row. Reuses the real get-contract `data` shape * (id/username/email/status/createdAt/updatedAt) so the row type stays @@ -42,3 +52,38 @@ export async function listUsers(params: ListUsersParams = {}): Promise(USERS_URL, { params }) return res.data } + +/** POST /users — create a user identity. Resolves on success; rejects with the + * interceptor-mapped error for the caller (modal) to surface inline. */ +export async function createUser(body: HttpAuthUserCreateV1Request): Promise { + await http.post(USERS_URL, body) +} + +/** PATCH /users/{id} — partial edit (email / status / requirePasswordReset). */ +export async function patchUser(id: string, body: HttpAuthUserPatchV1Request): Promise { + await http.patch(userUrl(id), body) +} + +/** DELETE /users/{id}. */ +export async function deleteUser(id: string): Promise { + await http.delete(userUrl(id)) +} + +/** POST /users/{id}/lock. */ +export async function lockUser(id: string): Promise { + await http.post(`${userUrl(id)}/lock`) +} + +/** POST /users/{id}/unlock. */ +export async function unlockUser(id: string): Promise { + await http.post(`${userUrl(id)}/unlock`) +} + +/** POST /users/{id}/password — change password (old + new; rotates the session + * for self-service changes). */ +export async function changeUserPassword( + id: string, + body: HttpAuthUserChangePasswordV1Request, +): Promise { + await http.post(`${userUrl(id)}/password`, body) +} diff --git a/packages/access/src/components/ChangePasswordModal.spec.ts b/packages/access/src/components/ChangePasswordModal.spec.ts new file mode 100644 index 0000000..6cbc159 --- /dev/null +++ b/packages/access/src/components/ChangePasswordModal.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import { useIdentitiesStore } from '../stores/useIdentitiesStore' +import type { Identity } from '../api/identities' +import ChangePasswordModal from './ChangePasswordModal.vue' + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k: string) => k }) })) + +const user: Identity = { + id: 'u-1', + username: 'alice', + email: 'alice@corp.example', + status: 'active', + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', +} + +function mountModal(props: Record = {}) { + const wrapper = mount(ChangePasswordModal, { + props: { open: true, user, ...props }, + global: { plugins: [createTestingPinia({ createSpy: vi.fn })] }, + }) + const store = useIdentitiesStore() + return { wrapper, store } +} + +const fill = async ( + w: ReturnType['wrapper'], + oldP = 'oldpass1', + newP = 'New!2345', + confirm = 'New!2345', +) => { + await w.find('#cp-old').setValue(oldP) + await w.find('#cp-new').setValue(newP) + await w.find('#cp-confirm').setValue(confirm) +} + +describe('ChangePasswordModal', () => { + beforeEach(() => vi.clearAllMocks()) + + it('renders three password inputs when open', () => { + const { wrapper } = mountModal() + expect(wrapper.find('#cp-old').attributes('type')).toBe('password') + expect(wrapper.find('#cp-new').exists()).toBe(true) + expect(wrapper.find('#cp-confirm').exists()).toBe(true) + }) + + it('calls store.changePassword with old + new on a valid submit', async () => { + const { wrapper, store } = mountModal() + vi.mocked(store.changePassword).mockResolvedValue(undefined) + await fill(wrapper) + await wrapper.find('form').trigger('submit') + await flushPromises() + expect(store.changePassword).toHaveBeenCalledWith('u-1', { + oldPassword: 'oldpass1', + newPassword: 'New!2345', + }) + expect(wrapper.emitted('close')).toBeTruthy() + }) + + it('blocks submit and flags a mismatch', async () => { + const { wrapper, store } = mountModal() + await fill(wrapper, 'oldpass1', 'New!2345', 'Different!9') + await wrapper.find('form').trigger('submit') + expect(store.changePassword).not.toHaveBeenCalled() + expect(wrapper.find('#cp-confirm-error').text()).toBe( + 'access.identities.password.confirmMismatch', + ) + }) + + it('surfaces a server error and keeps the modal open', async () => { + const { wrapper, store } = mountModal() + vi.mocked(store.changePassword).mockRejectedValue({ + isAxiosError: true, + i18nKey: 'errors.ERR_VALIDATION', + }) + await fill(wrapper) + await wrapper.find('form').trigger('submit') + await flushPromises() + expect(wrapper.text()).toContain('errors.ERR_VALIDATION') + expect(wrapper.emitted('close')).toBeFalsy() + }) +}) diff --git a/packages/access/src/components/ChangePasswordModal.vue b/packages/access/src/components/ChangePasswordModal.vue new file mode 100644 index 0000000..cb7ecc2 --- /dev/null +++ b/packages/access/src/components/ChangePasswordModal.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/packages/access/src/components/ConfirmDialog.spec.ts b/packages/access/src/components/ConfirmDialog.spec.ts new file mode 100644 index 0000000..5c9fa45 --- /dev/null +++ b/packages/access/src/components/ConfirmDialog.spec.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import ConfirmDialog from './ConfirmDialog.vue' + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k: string) => k }) })) + +function mountDialog(props: Record = {}) { + return mount(ConfirmDialog, { + props: { + open: true, + titleKey: 'access.identities.confirm.delete.title', + messageKey: 'access.identities.confirm.delete.message', + confirmKey: 'access.identities.confirm.delete.confirm', + ...props, + }, + }) +} + +describe('ConfirmDialog', () => { + it('renders as an alertdialog labelled by its title', () => { + const w = mountDialog() + const panel = w.find('.modal__panel') + expect(panel.attributes('role')).toBe('alertdialog') + expect(panel.attributes('aria-labelledby')).toBe('confirm-dialog-title') + expect(panel.attributes('aria-describedby')).toBe('confirm-dialog-message') + expect(w.find('#confirm-dialog-title').text()).toBe('access.identities.confirm.delete.title') + expect(w.find('#confirm-dialog-message').exists()).toBe(true) + }) + + it('emits confirm when the confirm button is clicked', async () => { + const w = mountDialog() + const buttons = w.findAll('.confirm__btn') + await buttons[buttons.length - 1]!.trigger('click') + expect(w.emitted('confirm')).toBeTruthy() + }) + + it('emits cancel when the cancel button is clicked', async () => { + const w = mountDialog() + await w.findAll('.confirm__btn')[0]!.trigger('click') + expect(w.emitted('cancel')).toBeTruthy() + }) + + it('uses the default cancel key, overridable via the cancelKey prop', () => { + expect(mountDialog().findAll('.confirm__btn')[0]!.text()).toBe( + 'access.identities.confirm.cancel', + ) + expect(mountDialog({ cancelKey: 'common.cancel' }).findAll('.confirm__btn')[0]!.text()).toBe( + 'common.cancel', + ) + }) + + it('applies the danger style only when danger=true', () => { + expect(mountDialog({ danger: true }).find('.confirm__btn--danger').exists()).toBe(true) + expect(mountDialog({ danger: false }).find('.confirm__btn--danger').exists()).toBe(false) + }) + + it('disables the confirm button while busy', () => { + const w = mountDialog({ busy: true }) + const buttons = w.findAll('.confirm__btn') + expect(buttons[buttons.length - 1]!.attributes('disabled')).toBeDefined() + expect(buttons[buttons.length - 1]!.attributes('aria-busy')).toBe('true') + }) + + it('shows an inline server error when errorKey is set', () => { + const w = mountDialog({ errorKey: 'errors.ERR_INTERNAL' }) + const alert = w.find('[role="alert"]') + expect(alert.exists()).toBe(true) + expect(alert.text()).toBe('errors.ERR_INTERNAL') + }) +}) diff --git a/packages/access/src/components/ConfirmDialog.vue b/packages/access/src/components/ConfirmDialog.vue new file mode 100644 index 0000000..9caf3be --- /dev/null +++ b/packages/access/src/components/ConfirmDialog.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/packages/access/src/components/IdentityFormModal.spec.ts b/packages/access/src/components/IdentityFormModal.spec.ts new file mode 100644 index 0000000..f5ccdd0 --- /dev/null +++ b/packages/access/src/components/IdentityFormModal.spec.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import { useIdentitiesStore } from '../stores/useIdentitiesStore' +import type { Identity } from '../api/identities' +import IdentityFormModal from './IdentityFormModal.vue' + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k: string) => k }) })) + +const user: Identity = { + id: 'u-1', + username: 'alice', + email: 'alice@corp.example', + status: 'active', + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', +} + +function mountModal(props: Record = {}) { + const wrapper = mount(IdentityFormModal, { + props: { open: true, user: null, ...props }, + global: { plugins: [createTestingPinia({ createSpy: vi.fn })] }, + }) + const store = useIdentitiesStore() + return { wrapper, store } +} + +const fillCreate = async (w: ReturnType['wrapper']) => { + await w.find('#identity-username').setValue('bob') + await w.find('#identity-email').setValue('bob@corp.example') + await w.find('#identity-password').setValue('Secret!23') +} + +describe('IdentityFormModal', () => { + beforeEach(() => vi.clearAllMocks()) + + it('renders nothing when closed', () => { + const { wrapper } = mountModal({ open: false }) + expect(wrapper.find('#identity-form-title').exists()).toBe(false) + }) + + it('shows the create title and a password field in create mode', () => { + const { wrapper } = mountModal({ user: null }) + expect(wrapper.find('#identity-form-title').text()).toBe('access.identities.form.createTitle') + expect(wrapper.find('#identity-password').exists()).toBe(true) + expect(wrapper.find('#identity-username').attributes('readonly')).toBeUndefined() + }) + + it('prefills and locks username, hides password, in edit mode', () => { + const { wrapper } = mountModal({ user }) + expect(wrapper.find('#identity-form-title').text()).toBe('access.identities.form.editTitle') + expect((wrapper.find('#identity-email').element as HTMLInputElement).value).toBe( + 'alice@corp.example', + ) + expect(wrapper.find('#identity-username').attributes('readonly')).toBeDefined() + expect(wrapper.find('#identity-password').exists()).toBe(false) + }) + + it('blocks submit and shows field errors when create input is invalid', async () => { + const { wrapper, store } = mountModal({ user: null }) + await wrapper.find('form').trigger('submit') + expect(store.create).not.toHaveBeenCalled() + expect(wrapper.find('#identity-username-error').exists()).toBe(true) + expect(wrapper.find('#identity-username').attributes('aria-invalid')).toBe('true') + }) + + it('calls store.create with the form body and emits saved + close', async () => { + const { wrapper, store } = mountModal({ user: null }) + vi.mocked(store.create).mockResolvedValue(undefined) + await fillCreate(wrapper) + await wrapper.find('form').trigger('submit') + await flushPromises() + expect(store.create).toHaveBeenCalledWith({ + username: 'bob', + email: 'bob@corp.example', + password: 'Secret!23', + }) + expect(wrapper.emitted('saved')).toBeTruthy() + expect(wrapper.emitted('close')).toBeTruthy() + }) + + it('includes requirePasswordReset when the box is checked', async () => { + const { wrapper, store } = mountModal({ user: null }) + vi.mocked(store.create).mockResolvedValue(undefined) + await fillCreate(wrapper) + await wrapper.find('input[type="checkbox"]').setValue(true) + await wrapper.find('form').trigger('submit') + await flushPromises() + expect(store.create).toHaveBeenCalledWith( + expect.objectContaining({ requirePasswordReset: true }), + ) + }) + + it('calls store.edit with the user id and patched email in edit mode', async () => { + const { wrapper, store } = mountModal({ user }) + vi.mocked(store.edit).mockResolvedValue(undefined) + await wrapper.find('#identity-email').setValue('new@corp.example') + await wrapper.find('form').trigger('submit') + await flushPromises() + expect(store.edit).toHaveBeenCalledWith('u-1', { email: 'new@corp.example' }) + }) + + it('surfaces a server error and keeps the modal open', async () => { + const { wrapper, store } = mountModal({ user: null }) + vi.mocked(store.create).mockRejectedValue({ + isAxiosError: true, + i18nKey: 'errors.ERR_CONFLICT', + }) + await fillCreate(wrapper) + await wrapper.find('form').trigger('submit') + await flushPromises() + expect(wrapper.text()).toContain('errors.ERR_CONFLICT') + expect(wrapper.emitted('close')).toBeFalsy() + }) +}) diff --git a/packages/access/src/components/IdentityFormModal.vue b/packages/access/src/components/IdentityFormModal.vue new file mode 100644 index 0000000..4200396 --- /dev/null +++ b/packages/access/src/components/IdentityFormModal.vue @@ -0,0 +1,318 @@ + + + + + diff --git a/packages/access/src/lib/identityValidation.spec.ts b/packages/access/src/lib/identityValidation.spec.ts new file mode 100644 index 0000000..c9d653d --- /dev/null +++ b/packages/access/src/lib/identityValidation.spec.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest' +import { + validateCreateIdentity, + isCreateIdentityValid, + validateEditIdentity, + isEditIdentityValid, + validateChangePassword, + isChangePasswordValid, +} from './identityValidation' + +describe('validateCreateIdentity', () => { + const ok = { username: 'alice', email: 'alice@corp.example', password: 'Secret!23' } + + it('passes a well-formed form', () => { + expect(validateCreateIdentity(ok)).toEqual({ username: null, email: null, password: null }) + expect(isCreateIdentityValid(ok)).toBe(true) + }) + + it('flags a missing username then a malformed one', () => { + expect(validateCreateIdentity({ ...ok, username: '' }).username).toBe( + 'access.identities.form.username.required', + ) + expect(validateCreateIdentity({ ...ok, username: 'a' }).username).toBe( + 'access.identities.form.username.format', + ) + }) + + it('flags a missing email then a malformed one', () => { + expect(validateCreateIdentity({ ...ok, email: '' }).email).toBe( + 'access.identities.form.email.required', + ) + expect(validateCreateIdentity({ ...ok, email: 'nope' }).email).toBe( + 'access.identities.form.email.format', + ) + }) + + it('flags a missing, too-short, then control-char password', () => { + expect(validateCreateIdentity({ ...ok, password: '' }).password).toBe( + 'access.identities.form.password.required', + ) + expect(validateCreateIdentity({ ...ok, password: 'short' }).password).toBe( + 'access.identities.form.password.tooShort', + ) + expect(validateCreateIdentity({ ...ok, password: 'abcdef\x07gh' }).password).toBe( + 'access.identities.form.password.controlChar', + ) + }) + + it('isCreateIdentityValid is false when any field is invalid', () => { + expect(isCreateIdentityValid({ ...ok, email: 'bad' })).toBe(false) + }) +}) + +describe('validateEditIdentity', () => { + it('requires a valid email', () => { + expect(validateEditIdentity({ email: '' }).email).toBe('access.identities.form.email.required') + expect(validateEditIdentity({ email: 'bad' }).email).toBe('access.identities.form.email.format') + expect(validateEditIdentity({ email: 'ok@corp.example' }).email).toBeNull() + expect(isEditIdentityValid({ email: 'ok@corp.example' })).toBe(true) + }) +}) + +describe('validateChangePassword', () => { + const ok = { oldPassword: 'oldpass1', newPassword: 'New!2345', confirm: 'New!2345' } + + it('passes a well-formed form', () => { + expect(validateChangePassword(ok)).toEqual({ + oldPassword: null, + newPassword: null, + confirm: null, + }) + expect(isChangePasswordValid(ok)).toBe(true) + }) + + it('requires the old password', () => { + expect(validateChangePassword({ ...ok, oldPassword: '' }).oldPassword).toBe( + 'access.identities.password.oldRequired', + ) + }) + + it('validates the new password length', () => { + expect(validateChangePassword({ ...ok, newPassword: '', confirm: '' }).newPassword).toBe( + 'access.identities.password.newRequired', + ) + expect( + validateChangePassword({ ...ok, newPassword: 'short', confirm: 'short' }).newPassword, + ).toBe('access.identities.password.newTooShort') + }) + + it('requires confirm to match the new password', () => { + expect(validateChangePassword({ ...ok, confirm: '' }).confirm).toBe( + 'access.identities.password.confirmRequired', + ) + expect(validateChangePassword({ ...ok, confirm: 'Different!9' }).confirm).toBe( + 'access.identities.password.confirmMismatch', + ) + }) +}) diff --git a/packages/access/src/lib/identityValidation.ts b/packages/access/src/lib/identityValidation.ts new file mode 100644 index 0000000..1bd95b6 --- /dev/null +++ b/packages/access/src/lib/identityValidation.ts @@ -0,0 +1,100 @@ +/** + * identityValidation.ts — pure validators for the identity operation modals. + * + * Same contract as lib/validation.ts: validators return i18n KEY strings (never + * display text) or `null` when valid, so views map them through `t()` and the + * functions stay trivially unit-testable. Username/email rules + password checks + * are reused from lib/validation.ts (single source for those regexes). + */ +import { pwChecks, EMAIL_RE, USERNAME_RE } from './validation' + +export interface CreateIdentityForm { + username: string + email: string + password: string +} +export interface CreateIdentityErrors { + username: string | null + email: string | null + password: string | null +} + +export function validateCreateIdentity({ + username, + email, + password, +}: CreateIdentityForm): CreateIdentityErrors { + const out: CreateIdentityErrors = { username: null, email: null, password: null } + + if (!username) out.username = 'access.identities.form.username.required' + else if (!USERNAME_RE.test(username)) out.username = 'access.identities.form.username.format' + + if (!email) out.email = 'access.identities.form.email.required' + else if (!EMAIL_RE.test(email)) out.email = 'access.identities.form.email.format' + + const checks = pwChecks(password) + if (!password) out.password = 'access.identities.form.password.required' + else if (!checks.len) out.password = 'access.identities.form.password.tooShort' + else if (!checks.ctrl) out.password = 'access.identities.form.password.controlChar' + + return out +} + +export function isCreateIdentityValid(form: CreateIdentityForm): boolean { + const e = validateCreateIdentity(form) + return e.username === null && e.email === null && e.password === null +} + +export interface EditIdentityForm { + email: string +} +export interface EditIdentityErrors { + email: string | null +} + +export function validateEditIdentity({ email }: EditIdentityForm): EditIdentityErrors { + const out: EditIdentityErrors = { email: null } + if (!email) out.email = 'access.identities.form.email.required' + else if (!EMAIL_RE.test(email)) out.email = 'access.identities.form.email.format' + return out +} + +export function isEditIdentityValid(form: EditIdentityForm): boolean { + return validateEditIdentity(form).email === null +} + +export interface ChangePasswordForm { + oldPassword: string + newPassword: string + confirm: string +} +export interface ChangePasswordErrors { + oldPassword: string | null + newPassword: string | null + confirm: string | null +} + +export function validateChangePassword({ + oldPassword, + newPassword, + confirm, +}: ChangePasswordForm): ChangePasswordErrors { + const out: ChangePasswordErrors = { oldPassword: null, newPassword: null, confirm: null } + + if (!oldPassword) out.oldPassword = 'access.identities.password.oldRequired' + + const checks = pwChecks(newPassword) + if (!newPassword) out.newPassword = 'access.identities.password.newRequired' + else if (!checks.len) out.newPassword = 'access.identities.password.newTooShort' + else if (!checks.ctrl) out.newPassword = 'access.identities.password.newControlChar' + + if (!confirm) out.confirm = 'access.identities.password.confirmRequired' + else if (confirm !== newPassword) out.confirm = 'access.identities.password.confirmMismatch' + + return out +} + +export function isChangePasswordValid(form: ChangePasswordForm): boolean { + const e = validateChangePassword(form) + return e.oldPassword === null && e.newPassword === null && e.confirm === null +} diff --git a/packages/access/src/lib/validation.ts b/packages/access/src/lib/validation.ts index 695d9aa..557a9f6 100644 --- a/packages/access/src/lib/validation.ts +++ b/packages/access/src/lib/validation.ts @@ -11,8 +11,9 @@ // rejection of control chars in bootstrap creds), so no-control-regex is moot. // eslint-disable-next-line no-control-regex const CONTROL_CHAR_RE = /[\x00-\x08\x0E-\x1F\x7F]/ -const USERNAME_RE = /^[a-z0-9_.-]{3,32}$/i -const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ +// Exported for reuse by identity-management validators (same username/email rules). +export const USERNAME_RE = /^[a-z0-9_.-]{3,32}$/i +export const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ /** Trim leading/trailing whitespace (mirrors backend TrimSpace on bootstrap creds). */ function trim(s: string): string { diff --git a/packages/access/src/stores/useIdentitiesStore.spec.ts b/packages/access/src/stores/useIdentitiesStore.spec.ts index bc0431e..710c555 100644 --- a/packages/access/src/stores/useIdentitiesStore.spec.ts +++ b/packages/access/src/stores/useIdentitiesStore.spec.ts @@ -160,4 +160,91 @@ describe('useIdentitiesStore', () => { expect(mock.history.get).toHaveLength(0) }) }) + + describe('mutations', () => { + const okList = () => + mock.onGet(USERS_URL).reply(200, { data: [mkUser()], nextCursor: '', hasMore: false }) + + it('create POSTs the body then refetches the list', async () => { + const body = { username: 'bob', email: 'bob@corp.example', password: 'Secret!23' } + mock.onPost(USERS_URL).reply(201, { data: mkUser({ id: 'u-2' }) }) + okList() + await useIdentitiesStore().create(body) + expect(JSON.parse(mock.history.post[0]?.data as string)).toEqual(body) + expect(mock.history.get).toHaveLength(1) + }) + + it('edit PATCHes the item then refetches', async () => { + mock.onPatch(`${USERS_URL}/u-1`).reply(200, { data: mkUser() }) + okList() + await useIdentitiesStore().edit('u-1', { email: 'new@corp.example' }) + expect(mock.history.patch[0]?.url).toBe(`${USERS_URL}/u-1`) + expect(mock.history.get).toHaveLength(1) + }) + + it('lock POSTs the lock sub-resource then refetches', async () => { + mock.onPost(`${USERS_URL}/u-1/lock`).reply(200, { data: { status: 'locked' } }) + okList() + await useIdentitiesStore().lock('u-1') + expect(mock.history.post[0]?.url).toBe(`${USERS_URL}/u-1/lock`) + expect(mock.history.get).toHaveLength(1) + }) + + it('unlock POSTs the unlock sub-resource then refetches', async () => { + mock.onPost(`${USERS_URL}/u-1/unlock`).reply(200) + okList() + await useIdentitiesStore().unlock('u-1') + expect(mock.history.post[0]?.url).toBe(`${USERS_URL}/u-1/unlock`) + expect(mock.history.get).toHaveLength(1) + }) + + it('remove DELETEs the item then refetches', async () => { + mock.onDelete(`${USERS_URL}/u-1`).reply(204) + okList() + await useIdentitiesStore().remove('u-1') + expect(mock.history.delete[0]?.url).toBe(`${USERS_URL}/u-1`) + expect(mock.history.get).toHaveLength(1) + }) + + it('changePassword POSTs to the password sub-resource WITHOUT refetching', async () => { + mock.onPost(`${USERS_URL}/u-1/password`).reply(200, { data: {} }) + await useIdentitiesStore().changePassword('u-1', { + oldPassword: 'old', + newPassword: 'New!2345', + }) + expect(mock.history.post[0]?.url).toBe(`${USERS_URL}/u-1/password`) + expect(mock.history.get).toHaveLength(0) + }) + + it('refreshes the full loaded extent after a mutation (no collapse to page 1)', async () => { + const many = (n: number, p: string) => + Array.from({ length: n }, (_, i) => mkUser({ id: `${p}-${i}`, username: `u${p}${i}` })) + const store = useIdentitiesStore() + mock.onGet(USERS_URL).replyOnce(200, { data: many(50, 'a'), nextCursor: 'c2', hasMore: true }) + await store.fetchList() + mock.onGet(USERS_URL).replyOnce(200, { data: many(50, 'b'), nextCursor: '', hasMore: false }) + await store.loadMore() + expect(store.users).toHaveLength(100) + + let seenLimit: unknown + mock.onPost(`${USERS_URL}/a-0/lock`).reply(200, { data: { status: 'locked' } }) + mock.onGet(USERS_URL).reply((c) => { + seenLimit = (c.params as Record).limit + return [200, { data: many(100, 'a'), nextCursor: '', hasMore: false }] + }) + await store.lock('a-0') + // refetch covers the loaded extent (100), not just the first page (50) + expect(seenLimit).toBe(100) + expect(store.users).toHaveLength(100) + }) + + it('re-throws and does NOT refetch when a mutation fails', async () => { + mock.onPost(USERS_URL).networkError() + const store = useIdentitiesStore() + await expect( + store.create({ username: 'x', email: 'x@y.z', password: 'Secret!23' }), + ).rejects.toThrow() + expect(mock.history.get).toHaveLength(0) + }) + }) }) diff --git a/packages/access/src/stores/useIdentitiesStore.ts b/packages/access/src/stores/useIdentitiesStore.ts index 628e0f3..f91abdb 100644 --- a/packages/access/src/stores/useIdentitiesStore.ts +++ b/packages/access/src/stores/useIdentitiesStore.ts @@ -1,17 +1,34 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { toI18nKey } from '@gocell/request' -import { listUsers, type Identity } from '../api/identities' +import type { + HttpAuthUserCreateV1Request, + HttpAuthUserPatchV1Request, + HttpAuthUserChangePasswordV1Request, +} from '@gocell/contracts' +import { + listUsers, + createUser, + patchUser, + deleteUser, + lockUser, + unlockUser, + changeUserPassword, + type Identity, +} from '../api/identities' /** Server page size for the cursor-paginated user list (BR-005). */ const PAGE_SIZE = 50 /** - * access.identities — user-identity list state (MVP: `type=user`). + * access.identities — user-identity state (MVP: `type=user`). * * Owns server-list state (rows, cursor, loading, error) + a client-side - * quick-filter. Mutation actions (create/lock/unlock/change-password/…) land - * with the operation modals (PR-10); this slice is read-only. + * quick-filter, plus the mutation actions backing the operation modals. + * + * List fetches swallow errors into `errorKey` (rendered inline on the page). + * Mutations DO NOT swallow — they re-throw so the triggering modal can show the + * error and stay open; on success they re-fetch the list as the source of truth. */ export const useIdentitiesStore = defineStore('access.identities', () => { // ─── state ────────────────────────────────────────────────────────────── @@ -32,7 +49,7 @@ export const useIdentitiesStore = defineStore('access.identities', () => { ) }) - // ─── actions ────────────────────────────────────────────────────────────── + // ─── read actions ─────────────────────────────────────────────────────── /** Fetch the first page, replacing any current rows. */ async function fetchList(): Promise { @@ -68,6 +85,62 @@ export const useIdentitiesStore = defineStore('access.identities', () => { } } + /** + * Re-fetch the rows currently in view (not just page 1) so a mutation never + * silently collapses pages the user expanded via loadMore. One request, fresh + * server truth, no optimistic drift. Errors surface inline like fetchList. + */ + async function refreshLoaded(): Promise { + const limit = Math.max(users.value.length, PAGE_SIZE) + loading.value = true + errorKey.value = null + try { + const page = await listUsers({ limit }) + users.value = page.data + nextCursor.value = page.nextCursor + hasMore.value = page.hasMore + } catch (err: unknown) { + errorKey.value = toI18nKey(err) + } finally { + loading.value = false + } + } + + // ─── mutation actions (re-throw on failure; refresh loaded extent on success) ── + + async function create(body: HttpAuthUserCreateV1Request): Promise { + await createUser(body) + await refreshLoaded() + } + + async function edit(id: string, body: HttpAuthUserPatchV1Request): Promise { + await patchUser(id, body) + await refreshLoaded() + } + + async function lock(id: string): Promise { + await lockUser(id) + await refreshLoaded() + } + + async function unlock(id: string): Promise { + await unlockUser(id) + await refreshLoaded() + } + + async function remove(id: string): Promise { + await deleteUser(id) + await refreshLoaded() + } + + /** Change password — no list refetch (the row's visible fields don't change). */ + async function changePassword( + id: string, + body: HttpAuthUserChangePasswordV1Request, + ): Promise { + await changeUserPassword(id, body) + } + return { // state users, @@ -78,8 +151,15 @@ export const useIdentitiesStore = defineStore('access.identities', () => { filter, // getters filteredUsers, - // actions + // read actions fetchList, loadMore, + // mutation actions + create, + edit, + lock, + unlock, + remove, + changePassword, } }) diff --git a/packages/access/src/views/IdentitiesView.spec.ts b/packages/access/src/views/IdentitiesView.spec.ts index 548c309..744d6ad 100644 --- a/packages/access/src/views/IdentitiesView.spec.ts +++ b/packages/access/src/views/IdentitiesView.spec.ts @@ -1,13 +1,16 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' -import { nextTick } from 'vue' +import { nextTick, computed } from 'vue' import { createTestingPinia } from '@pinia/testing' +import { PDP_INJECTION_KEY, type PdpClient } from '@gocell/core' import { useIdentitiesStore } from '../stores/useIdentitiesStore' import type { Identity } from '../api/identities' import IdentitiesView from './IdentitiesView.vue' vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k: string) => k }) })) +const pdp = (allowed: boolean): PdpClient => ({ can: () => computed(() => allowed) }) + const mkUser = (over: Partial = {}): Identity => ({ id: 'u-1', username: 'alice', @@ -177,3 +180,114 @@ describe('IdentitiesView', () => { expect(wrapper.find('[data-action="load-more"]').exists()).toBe(false) }) }) + +describe('IdentitiesView · operations (Can + modals)', () => { + function mountWithPdp(allowed: boolean | undefined, users = [mkUser()]) { + const provide: Record = {} + if (allowed !== undefined) provide[PDP_INJECTION_KEY] = pdp(allowed) + const wrapper = mount(IdentitiesView, { + global: { + plugins: [ + createTestingPinia({ + createSpy: vi.fn, + initialState: { + 'access.identities': { + users, + loading: false, + errorKey: null, + nextCursor: '', + hasMore: false, + filter: '', + }, + }, + }), + ], + provide, + }, + }) + return { wrapper, store: useIdentitiesStore() } + } + + const actionBtn = (w: ReturnType['wrapper'], key: string) => + w.findAll('.identities__action').find((b) => b.text() === key) + + it('shows the create button only when the PDP allows it', () => { + expect(mountWithPdp(true).wrapper.find('[data-action="create"]').exists()).toBe(true) + expect(mountWithPdp(false).wrapper.find('[data-action="create"]').exists()).toBe(false) + }) + + it('hides the create button fail-closed when no PDP provider is mounted', () => { + expect(mountWithPdp(undefined).wrapper.find('[data-action="create"]').exists()).toBe(false) + }) + + it('renders row action buttons when the PDP allows', () => { + const { wrapper } = mountWithPdp(true) + expect(actionBtn(wrapper, 'access.identities.actions.edit')).toBeDefined() + expect(actionBtn(wrapper, 'access.identities.actions.delete')).toBeDefined() + }) + + it('gives each row action button an aria-label (username-scoped for screen readers)', () => { + const { wrapper } = mountWithPdp(true) + const edit = actionBtn(wrapper, 'access.identities.actions.edit')! + expect(edit.attributes('aria-label')).toContain('access.identities.actions.edit') + }) + + it('hides all row action buttons fail-closed when the PDP denies', () => { + const { wrapper } = mountWithPdp(false) + expect(wrapper.findAll('.identities__action')).toHaveLength(0) + }) + + it('opens the create modal when the create button is clicked', async () => { + const { wrapper } = mountWithPdp(true) + await wrapper.find('[data-action="create"]').trigger('click') + await nextTick() + expect(wrapper.find('#identity-form-title').text()).toBe('access.identities.form.createTitle') + }) + + it('opens the edit modal in edit mode when a row edit button is clicked', async () => { + const { wrapper } = mountWithPdp(true) + await actionBtn(wrapper, 'access.identities.actions.edit')!.trigger('click') + await nextTick() + expect(wrapper.find('#identity-form-title').text()).toBe('access.identities.form.editTitle') + }) + + it('opens the change-password modal when its action is clicked', async () => { + const { wrapper } = mountWithPdp(true) + await actionBtn(wrapper, 'access.identities.actions.changePassword')!.trigger('click') + await nextTick() + expect(wrapper.find('#change-password-title').exists()).toBe(true) + }) + + it('shows a Lock action for an active user and Unlock for a locked one', () => { + const active = mountWithPdp(true, [mkUser({ status: 'active' })]).wrapper + expect(actionBtn(active, 'access.identities.actions.lock')).toBeDefined() + expect(actionBtn(active, 'access.identities.actions.unlock')).toBeUndefined() + + const locked = mountWithPdp(true, [mkUser({ status: 'locked' })]).wrapper + expect(actionBtn(locked, 'access.identities.actions.unlock')).toBeDefined() + expect(actionBtn(locked, 'access.identities.actions.lock')).toBeUndefined() + }) + + it('opens an alertdialog confirm when a destructive action is clicked', async () => { + const { wrapper } = mountWithPdp(true) + await actionBtn(wrapper, 'access.identities.actions.delete')!.trigger('click') + await nextTick() + const panel = wrapper.find('.modal__panel') + expect(panel.attributes('role')).toBe('alertdialog') + expect(wrapper.find('#confirm-dialog-title').text()).toBe( + 'access.identities.confirm.delete.title', + ) + }) + + it('runs store.remove and closes the dialog on delete confirm', async () => { + const { wrapper, store } = mountWithPdp(true) + vi.mocked(store.remove).mockResolvedValue(undefined) + await actionBtn(wrapper, 'access.identities.actions.delete')!.trigger('click') + await nextTick() + const buttons = wrapper.findAll('.confirm__btn') + await buttons[buttons.length - 1]!.trigger('click') + await flushPromises() + expect(store.remove).toHaveBeenCalledWith('u-1') + expect(wrapper.find('.modal__panel').exists()).toBe(false) + }) +}) diff --git a/packages/access/src/views/IdentitiesView.vue b/packages/access/src/views/IdentitiesView.vue index 2eeb0f3..f1e4757 100644 --- a/packages/access/src/views/IdentitiesView.vue +++ b/packages/access/src/views/IdentitiesView.vue @@ -4,17 +4,24 @@ * * Reads the cursor-paginated user list from `useIdentitiesStore` and renders a * hand-rolled semantic table + client-side quick-filter (BR-005: no server - * filter yet). Row actions (edit / lock / change-password …) and their `` - * gating land with the operation modals (PR-10); this page is read-only. + * filter yet). Row operations (create / edit / change-password / lock / unlock / + * delete) open modals; every action button is gated by `` (fail-closed: + * hidden unless the PDP allows), and the route also carries `meta.requiredAction`. * * The "Service accounts" tab is a disabled placeholder (FR-030): MVP manages * only user identities; service-account / cell-as-principal support is Wave 2+. */ -import { onMounted } from 'vue' +import { computed, onMounted, ref } from 'vue' import { useI18n } from 'vue-i18n' import { storeToRefs } from 'pinia' +import { Can } from '@gocell/core' +import { isGoCellRequestError } from '@gocell/request' import { useIdentitiesStore } from '../stores/useIdentitiesStore' +import type { Identity } from '../api/identities' import IdentityStatusPill from '../components/IdentityStatusPill.vue' +import IdentityFormModal from '../components/IdentityFormModal.vue' +import ChangePasswordModal from '../components/ChangePasswordModal.vue' +import ConfirmDialog from '../components/ConfirmDialog.vue' const { t } = useI18n() const store = useIdentitiesStore() @@ -24,19 +31,100 @@ onMounted(() => { void store.fetchList() }) -/** Locale-aware timestamp (Intl, never a hand-rolled format string). */ +/** Locale-aware timestamp (Intl, never a hand-rolled format string). One formatter + * instance reused across all rows/renders — constructing it per call is ~10× costlier. */ +const dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }) function formatDate(iso: string): string { const d = new Date(iso) if (Number.isNaN(d.getTime())) return iso - return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(d) + return dateFormat.format(d) +} + +/** Screen-reader name for a row action button: action verb + the row's username + * (so repeated buttons aren't announced as a bare "Edit, Edit, Edit…"). */ +function actionLabel(verbKey: string, username: string): string { + return t(verbKey) + t('access.identities.actions.rowSuffix', { username }) +} + +// ─── operation modals ─────────────────────────────────────────────────────── +const formOpen = ref(false) +const formUser = ref(null) +const pwOpen = ref(false) +const pwUser = ref(null) + +type ConfirmAction = 'lock' | 'unlock' | 'delete' +const confirmState = ref<{ action: ConfirmAction; user: Identity } | null>(null) +const confirmBusy = ref(false) +const confirmErrorKey = ref(null) + +function openCreate(): void { + formUser.value = null + formOpen.value = true +} +function openEdit(user: Identity): void { + formUser.value = user + formOpen.value = true +} +function openChangePassword(user: Identity): void { + pwUser.value = user + pwOpen.value = true +} +function askConfirm(action: ConfirmAction, user: Identity): void { + confirmErrorKey.value = null + confirmState.value = { action, user } +} + +const confirmAction = computed(() => confirmState.value?.action ?? null) +const confirmTitleKey = computed(() => + confirmAction.value ? `access.identities.confirm.${confirmAction.value}.title` : '', +) +const confirmMessageKey = computed(() => + confirmAction.value ? `access.identities.confirm.${confirmAction.value}.message` : '', +) +const confirmConfirmKey = computed(() => + confirmAction.value ? `access.identities.confirm.${confirmAction.value}.confirm` : '', +) +const confirmDanger = computed(() => confirmAction.value === 'delete') + +async function onConfirm(): Promise { + if (!confirmState.value) return + const { action, user } = confirmState.value + confirmBusy.value = true + confirmErrorKey.value = null + try { + // Explicit per-action dispatch — no catch-all, so a future ConfirmAction + // can never silently fall through to the destructive remove(). + if (action === 'lock') await store.lock(user.id) + else if (action === 'unlock') await store.unlock(user.id) + else if (action === 'delete') await store.remove(user.id) + else { + // Compile-time exhaustiveness: a new ConfirmAction must add a branch above. + const _never: never = action + void _never + } + confirmState.value = null + } catch (err: unknown) { + confirmErrorKey.value = isGoCellRequestError(err) + ? (err.i18nKey ?? 'errors.unknown') + : 'errors.unknown' + } finally { + confirmBusy.value = false + } } @@ -158,6 +325,10 @@ function formatDate(iso: string): string { } .identities__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; margin-bottom: 20px; } @@ -176,6 +347,27 @@ function formatDate(iso: string): string { color: var(--fg-muted); } +.identities__create { + flex-shrink: 0; + height: 34px; + padding: 0 14px; + font-size: 13px; + font-weight: 500; + color: var(--bg); + background: var(--fg); + border: 1px solid var(--fg); + border-radius: var(--r); +} + +.identities__create:hover { + background: var(--fg-hover); +} + +.identities__create:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + .identities__tabs { display: flex; gap: 4px; @@ -293,6 +485,35 @@ function formatDate(iso: string): string { font-variant-numeric: tabular-nums; } +.identities__cell--actions { + display: flex; + gap: 6px; +} + +.identities__action { + min-height: 30px; + padding: 0 10px; + font-size: 12px; + color: var(--fg-muted); + background: var(--bg-raised); + border: 1px solid var(--line); + border-radius: var(--r-sm); +} + +.identities__action:hover { + color: var(--fg); + background: var(--bg-sunken); +} + +.identities__action:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.identities__action--danger { + color: var(--err-strong); +} + .identities__more { margin-top: 16px; height: 32px; diff --git a/packages/core/src/components/ModalShell.spec.ts b/packages/core/src/components/ModalShell.spec.ts new file mode 100644 index 0000000..62f426a --- /dev/null +++ b/packages/core/src/components/ModalShell.spec.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import ModalShell from './ModalShell.vue' + +const SLOT = '

Title

' + +function mountShell(props: Record = {}) { + return mount(ModalShell, { + props: { open: false, titleId: 't', ...props }, + slots: { default: SLOT }, + attachTo: document.body, + }) +} + +describe('ModalShell', () => { + afterEach(() => { + document.body.innerHTML = '' + }) + + it('renders nothing when closed', () => { + const w = mountShell({ open: false }) + expect(w.find('.modal__panel').exists()).toBe(false) + }) + + it('renders an aria-modal dialog labelled by titleId when open', async () => { + const w = mountShell({ open: true }) + await flushPromises() + const panel = w.find('.modal__panel') + expect(panel.exists()).toBe(true) + expect(panel.attributes('role')).toBe('dialog') + expect(panel.attributes('aria-modal')).toBe('true') + expect(panel.attributes('aria-labelledby')).toBe('t') + }) + + it('reflects descriptionId as aria-describedby (for alertdialog)', async () => { + const w = mountShell({ open: true, role: 'alertdialog', descriptionId: 'd' }) + await flushPromises() + expect(w.find('.modal__panel').attributes('aria-describedby')).toBe('d') + }) + + it('uses role=alertdialog when requested', async () => { + const w = mountShell({ open: true, role: 'alertdialog' }) + await flushPromises() + expect(w.find('.modal__panel').attributes('role')).toBe('alertdialog') + }) + + it('emits close on Escape', async () => { + const w = mountShell({ open: true }) + await flushPromises() + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + expect(w.emitted('close')).toBeTruthy() + }) + + it('emits close on backdrop click but not on a click inside the panel', async () => { + const w = mountShell({ open: true }) + await flushPromises() + await w.find('.modal__panel').trigger('click') + expect(w.emitted('close')).toBeFalsy() + await w.find('.modal__backdrop').trigger('click') + expect(w.emitted('close')).toBeTruthy() + }) + + it('moves focus to the first focusable element when opened', async () => { + const w = mountShell({ open: false }) + await w.setProps({ open: true }) + await flushPromises() + expect(document.activeElement?.id).toBe('b1') + }) + + it('restores focus to the opener when closed', async () => { + const opener = document.createElement('button') + opener.id = 'opener' + document.body.appendChild(opener) + opener.focus() + const w = mountShell({ open: false }) + await w.setProps({ open: true }) + await flushPromises() + await w.setProps({ open: false }) + expect(document.activeElement?.id).toBe('opener') + }) + + it('marks background siblings inert while open and restores them on close', async () => { + const bg = document.createElement('div') + bg.id = 'bg' + document.body.appendChild(bg) + const w = mountShell({ open: false }) + await w.setProps({ open: true }) + await flushPromises() + expect(bg.inert).toBe(true) + await w.setProps({ open: false }) + expect(bg.inert).toBe(false) + }) + + it('traps Tab from the last focusable back to the first', async () => { + mountShell({ open: true }) + await flushPromises() + ;(document.getElementById('b2') as HTMLElement).focus() + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', cancelable: true })) + expect(document.activeElement?.id).toBe('b1') + }) + + it('traps Shift+Tab from the first focusable to the last', async () => { + mountShell({ open: true }) + await flushPromises() + ;(document.getElementById('b1') as HTMLElement).focus() + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, cancelable: true }), + ) + expect(document.activeElement?.id).toBe('b2') + }) + + it('keeps focus on the panel when there are no focusable children', async () => { + const w = mount(ModalShell, { + props: { open: true, titleId: 't' }, + slots: { default: '

nothing focusable here

' }, + attachTo: document.body, + }) + await flushPromises() + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', cancelable: true })) + expect(document.activeElement).toBe(w.find('.modal__panel').element) + }) + + it('removes its key listener on unmount (no close after teardown)', async () => { + const w = mountShell({ open: true }) + await flushPromises() + w.unmount() + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + // no throw / no late emit — listener was cleaned up + expect(true).toBe(true) + }) +}) diff --git a/packages/core/src/components/ModalShell.vue b/packages/core/src/components/ModalShell.vue new file mode 100644 index 0000000..5275fc2 --- /dev/null +++ b/packages/core/src/components/ModalShell.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index ee08de9..07e209e 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -1 +1,2 @@ export { default as Can } from './Can.vue' +export { default as ModalShell } from './ModalShell.vue' diff --git a/packages/core/src/i18n/messages/en-US.ts b/packages/core/src/i18n/messages/en-US.ts index d5d15d7..39c4f1d 100644 --- a/packages/core/src/i18n/messages/en-US.ts +++ b/packages/core/src/i18n/messages/en-US.ts @@ -286,6 +286,7 @@ const enUS = { email: 'Email', status: 'Status', createdAt: 'Created', + actions: 'Actions', }, status: { active: 'Active', @@ -295,6 +296,78 @@ const enUS = { empty: 'No users yet', loadMore: 'Load more', resultCount: '{count} users', + actions: { + create: 'New user', + edit: 'Edit', + changePassword: 'Password', + lock: 'Lock', + unlock: 'Unlock', + delete: 'Delete', + // Screen-reader suffix for row action buttons — appends the username for context (e.g. "Edit alice") + rowSuffix: ' {username}', + }, + form: { + createTitle: 'New user', + editTitle: 'Edit user', + username: { + label: 'Username', + placeholder: 'alice', + required: 'Username is required', + format: '3–32 chars: letters / digits / . _ -', + }, + email: { + label: 'Email', + // vue-i18n treats a bare `@` as a linked-message prefix; escape it. + placeholder: "alice{'@'}corp.example", + required: 'Email is required', + format: 'Enter a valid email address', + }, + password: { + label: 'Initial password', + placeholder: 'Long passphrase: mixed case + digits + symbols', + required: 'Password is required', + tooShort: 'Must be 8–72 bytes (bcrypt limit)', + controlChar: 'Control characters are not allowed', + }, + requirePasswordReset: 'Require a password change after first sign-in', + cancel: 'Cancel', + submit: 'Save', + submitting: 'Saving…', + }, + password: { + title: 'Change password', + old: { label: 'Current password' }, + new: { label: 'New password' }, + confirm: { label: 'Confirm new password' }, + oldRequired: 'Enter the current password', + newRequired: 'Enter a new password', + newTooShort: 'Must be 8–72 bytes (bcrypt limit)', + newControlChar: 'Control characters are not allowed', + confirmRequired: 'Re-enter the new password', + confirmMismatch: 'Passwords do not match', + cancel: 'Cancel', + submit: 'Change password', + submitting: 'Submitting…', + }, + confirm: { + cancel: 'Cancel', + lock: { + title: 'Lock user', + message: 'A locked user cannot sign in. You can unlock them at any time. Lock this user?', + confirm: 'Lock', + }, + unlock: { + title: 'Unlock user', + message: 'Unlocking restores this user’s ability to sign in. Unlock this user?', + confirm: 'Unlock', + }, + delete: { + title: 'Delete user', + message: + 'This permanently removes the user and their sessions. This cannot be undone. Delete?', + confirm: 'Delete', + }, + }, }, }, errors: { diff --git a/packages/core/src/i18n/messages/zh-CN.ts b/packages/core/src/i18n/messages/zh-CN.ts index eb419a6..7f55a1c 100644 --- a/packages/core/src/i18n/messages/zh-CN.ts +++ b/packages/core/src/i18n/messages/zh-CN.ts @@ -279,6 +279,7 @@ const zhCN = { email: '邮箱', status: '状态', createdAt: '创建时间', + actions: '操作', }, status: { active: '正常', @@ -288,6 +289,77 @@ const zhCN = { empty: '暂无用户', loadMore: '加载更多', resultCount: '{count} 个用户', + actions: { + create: '新建用户', + edit: '编辑', + changePassword: '改密', + lock: '锁定', + unlock: '解锁', + delete: '删除', + // 行操作按钮的读屏后缀:拼在动作名后给出用户名上下文(如「编辑 alice」) + rowSuffix: ' {username}', + }, + form: { + createTitle: '新建用户', + editTitle: '编辑用户', + username: { + label: '用户名', + placeholder: 'alice', + required: '请输入用户名', + format: '3–32 字符,字母 / 数字 / . _ -', + }, + email: { + label: '邮箱', + // vue-i18n 把 `@` 当作 linked-message 前缀,邮箱示例须用字面插值转义 + placeholder: "alice{'@'}corp.example", + required: '请输入邮箱', + format: '请输入有效的邮箱地址', + }, + password: { + label: '初始密码', + placeholder: '长口令,大小写 + 数字 + 符号', + required: '请输入密码', + tooShort: '需 8–72 字节(bcrypt 上限)', + controlChar: '不能包含控制字符', + }, + requirePasswordReset: '要求首次登录后修改密码', + cancel: '取消', + submit: '保存', + submitting: '保存中…', + }, + password: { + title: '修改密码', + old: { label: '当前密码' }, + new: { label: '新密码' }, + confirm: { label: '确认新密码' }, + oldRequired: '请输入当前密码', + newRequired: '请输入新密码', + newTooShort: '需 8–72 字节(bcrypt 上限)', + newControlChar: '不能包含控制字符', + confirmRequired: '请再次输入新密码', + confirmMismatch: '两次密码不一致', + cancel: '取消', + submit: '修改密码', + submitting: '提交中…', + }, + confirm: { + cancel: '取消', + lock: { + title: '锁定用户', + message: '锁定后该用户将无法登录,可随时解锁。确认锁定?', + confirm: '锁定', + }, + unlock: { + title: '解锁用户', + message: '解锁后该用户可恢复登录。确认解锁?', + confirm: '解锁', + }, + delete: { + title: '删除用户', + message: '删除后该用户及其会话将被移除,且不可恢复。确认删除?', + confirm: '删除', + }, + }, }, }, errors: { diff --git a/packages/core/src/styles/tokens.css b/packages/core/src/styles/tokens.css index 8dcb157..a9f3c75 100644 --- a/packages/core/src/styles/tokens.css +++ b/packages/core/src/styles/tokens.css @@ -18,6 +18,9 @@ --fg: oklch(0.18 0.008 270); --fg-muted: oklch(0.45 0.008 270); --fg-faint: oklch(0.62 0.006 270); + /* Hover shade of --fg for solid/inverted buttons — references --fg lazily so it + tracks the theme. Replaces ad-hoc oklch(from var(--fg) ...) in button hovers. */ + --fg-hover: oklch(from var(--fg) calc(l + 0.08) c h); /* single accent */ --accent: oklch(0.58 0.19 268); @@ -28,6 +31,9 @@ --ok: oklch(0.62 0.16 150); --warn: oklch(0.72 0.16 75); --err: oklch(0.58 0.22 25); + /* High-contrast error: darker in light theme so it clears WCAG AA both as text + on light surfaces and (with inverted --bg text) as a filled danger button. */ + --err-strong: oklch(0.47 0.2 25); /* radii / shadow */ --r-sm: 4px; @@ -47,6 +53,9 @@ --sidebar-collapsed-width: 52px; --topbar-height: 44px; + /* stacking layers */ + --z-modal: 1000; + /* overlay backdrop */ --overlay-bg: oklch(0 0 0 / 0.35); } @@ -60,11 +69,18 @@ --fg: oklch(0.96 0.004 270); --fg-muted: oklch(0.68 0.008 270); --fg-faint: oklch(0.48 0.008 270); + /* Dark theme: --fg is near-white, so lightening for hover is imperceptible — + darken instead for a visible hover on solid (--fg-backed) buttons. */ + --fg-hover: oklch(from var(--fg) calc(l - 0.06) c h); --accent: oklch(0.72 0.17 268); --accent-soft: oklch(0.28 0.08 268); --accent-fg: oklch(0.14 0.006 270); + /* Lighter in dark theme: high contrast as text on dark surfaces and (with + inverted --bg text) as a filled danger button. */ + --err-strong: oklch(0.72 0.18 25); + --shadow-sm: 0 1px 0 rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.04); --shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 4px 12px rgba(0, 0, 0, 0.45); --shadow-lg: 0 8px 28px rgba(0, 0, 0, 0.5), 0 2px 4px rgba(0, 0, 0, 0.35);