From 7d55c04ec91709b1e82c0b6ae616703d7d2c2130 Mon Sep 17 00:00:00 2001 From: ghbvf <104540935+ghbvf@users.noreply.github.com> Date: Sat, 30 May 2026 02:09:29 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(access):=20Identities=20=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=20modal=20+=20=20=E6=9D=83=E9=99=90=E9=97=A8=20(?= =?UTF-8?q?PR-10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch 2 · Access · Identities 第二段(操作;#13 · T203 · T205),stacked 于 PR-09。 - api/identities:create/patch/delete/lock/unlock/changeUserPassword (edit 走 PATCH,是 email-only PUT update 的超集,UI 不另用 update) - useIdentitiesStore:create/edit/lock/unlock/remove/changePassword mutation actions — 失败 re-throw(modal 内联展示并保持打开),成功 await fetchList()(changePassword 不 refetch) - components/ModalShell:a11y modal 原语(hand-rolled focus-trap + ESC + backdrop + 焦点回归;jsdom 无 HTMLDialogElement.showModal);第二个 cell 复用时再上提 @gocell/core - components/IdentityFormModal(create/edit)· ChangePasswordModal · ConfirmDialog(lock/unlock/delete,role=alertdialog) - lib/identityValidation:create/edit/change-password 校验(复用 validation.ts 的 EMAIL_RE/USERNAME_RE/pwChecks,返回 i18n key) - IdentitiesView:行操作列 + Create 按钮,每个动作挂 (fail-closed 隐藏);wire 三个 modal - apps/web 路由 /access/identities 挂 meta.requiredAction='read' + requiredResource='identity'(guards.ts PDP fail-closed gate) - core i18n:access.identities.{actions,form,password,confirm}.* 双语 (email 占位符 @ 转义;规避 vue-i18n linked-message trap) access 194 通过、整包 ~97.5% lines;workspace 511 通过;contracts clean。 Closes #13 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/router/index.spec.ts | 7 +- apps/web/src/router/index.ts | 9 +- packages/access/README.md | 15 +- packages/access/src/api/identities.spec.ts | 78 ++++- packages/access/src/api/identities.ts | 51 ++- .../components/ChangePasswordModal.spec.ts | 84 +++++ .../src/components/ChangePasswordModal.vue | 247 ++++++++++++++ .../src/components/ConfirmDialog.spec.ts | 59 ++++ .../access/src/components/ConfirmDialog.vue | 123 +++++++ .../src/components/IdentityFormModal.spec.ts | 115 +++++++ .../src/components/IdentityFormModal.vue | 312 ++++++++++++++++++ .../access/src/components/ModalShell.spec.ts | 113 +++++++ packages/access/src/components/ModalShell.vue | 128 +++++++ .../access/src/lib/identityValidation.spec.ts | 98 ++++++ packages/access/src/lib/identityValidation.ts | 100 ++++++ packages/access/src/lib/validation.ts | 5 +- .../src/stores/useIdentitiesStore.spec.ts | 65 ++++ .../access/src/stores/useIdentitiesStore.ts | 71 +++- .../access/src/views/IdentitiesView.spec.ts | 110 +++++- packages/access/src/views/IdentitiesView.vue | 193 ++++++++++- packages/core/src/i18n/messages/en-US.ts | 71 ++++ packages/core/src/i18n/messages/zh-CN.ts | 70 ++++ 22 files changed, 2095 insertions(+), 29 deletions(-) create mode 100644 packages/access/src/components/ChangePasswordModal.spec.ts create mode 100644 packages/access/src/components/ChangePasswordModal.vue create mode 100644 packages/access/src/components/ConfirmDialog.spec.ts create mode 100644 packages/access/src/components/ConfirmDialog.vue create mode 100644 packages/access/src/components/IdentityFormModal.spec.ts create mode 100644 packages/access/src/components/IdentityFormModal.vue create mode 100644 packages/access/src/components/ModalShell.spec.ts create mode 100644 packages/access/src/components/ModalShell.vue create mode 100644 packages/access/src/lib/identityValidation.spec.ts create mode 100644 packages/access/src/lib/identityValidation.ts 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 41573f6..163fbf3 100644 --- a/packages/access/README.md +++ b/packages/access/README.md @@ -15,7 +15,10 @@ 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,ModalShell,IdentityFormModal,ChangePasswordModal,ConfirmDialog}.vue` +> 是包内私有模块(不在 `exports`),仅供本包 views / store 消费。`ModalShell` 是 a11y modal 原语 +> (focus-trap + ESC + 焦点回归),第二个 cell 需要时再上提到 `@gocell/core`。 ## 依赖的 contract @@ -26,6 +29,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 +64,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 +88,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..0f931e7 --- /dev/null +++ b/packages/access/src/components/ChangePasswordModal.vue @@ -0,0 +1,247 @@ + + + + + diff --git a/packages/access/src/components/ConfirmDialog.spec.ts b/packages/access/src/components/ConfirmDialog.spec.ts new file mode 100644 index 0000000..130c2f7 --- /dev/null +++ b/packages/access/src/components/ConfirmDialog.spec.ts @@ -0,0 +1,59 @@ +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(w.find('#confirm-dialog-title').text()).toBe('access.identities.confirm.delete.title') + }) + + 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('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..24c8c1c --- /dev/null +++ b/packages/access/src/components/ConfirmDialog.vue @@ -0,0 +1,123 @@ + + + + + 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..5c0a661 --- /dev/null +++ b/packages/access/src/components/IdentityFormModal.vue @@ -0,0 +1,312 @@ + + + + + diff --git a/packages/access/src/components/ModalShell.spec.ts b/packages/access/src/components/ModalShell.spec.ts new file mode 100644 index 0000000..d3de225 --- /dev/null +++ b/packages/access/src/components/ModalShell.spec.ts @@ -0,0 +1,113 @@ +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('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('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/access/src/components/ModalShell.vue b/packages/access/src/components/ModalShell.vue new file mode 100644 index 0000000..951fcd4 --- /dev/null +++ b/packages/access/src/components/ModalShell.vue @@ -0,0 +1,128 @@ + + + + + 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..bdb93b4 100644 --- a/packages/access/src/stores/useIdentitiesStore.spec.ts +++ b/packages/access/src/stores/useIdentitiesStore.spec.ts @@ -160,4 +160,69 @@ 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('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..e0b4444 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,41 @@ export const useIdentitiesStore = defineStore('access.identities', () => { } } + // ─── mutation actions (re-throw on failure; refetch on success) ─────────── + + async function create(body: HttpAuthUserCreateV1Request): Promise { + await createUser(body) + await fetchList() + } + + async function edit(id: string, body: HttpAuthUserPatchV1Request): Promise { + await patchUser(id, body) + await fetchList() + } + + async function lock(id: string): Promise { + await lockUser(id) + await fetchList() + } + + async function unlock(id: string): Promise { + await unlockUser(id) + await fetchList() + } + + async function remove(id: string): Promise { + await deleteUser(id) + await fetchList() + } + + /** 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 +130,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 403e8ba..f41b260 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', @@ -158,3 +161,108 @@ 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('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 ccff6c3..2b011ff 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() @@ -30,13 +37,79 @@ function formatDate(iso: string): string { if (Number.isNaN(d.getTime())) return iso return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(d) } + +// ─── 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 { + if (action === 'lock') await store.lock(user.id) + else if (action === 'unlock') await store.unlock(user.id) + else await store.remove(user.id) + confirmState.value = null + } catch (err: unknown) { + confirmErrorKey.value = isGoCellRequestError(err) + ? (err.i18nKey ?? 'errors.unknown') + : 'errors.unknown' + } finally { + confirmBusy.value = false + } +} + + + + @@ -142,6 +271,10 @@ function formatDate(iso: string): string { } .identities__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; margin-bottom: 20px; } @@ -160,6 +293,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: oklch(from var(--fg) calc(l + 0.08) c h); +} + +.identities__create:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + .identities__tabs { display: flex; gap: 4px; @@ -276,6 +430,35 @@ function formatDate(iso: string): string { font-variant-numeric: tabular-nums; } +.identities__cell--actions { + display: flex; + gap: 6px; +} + +.identities__action { + height: 28px; + 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); +} + .identities__more { margin-top: 16px; height: 32px; diff --git a/packages/core/src/i18n/messages/en-US.ts b/packages/core/src/i18n/messages/en-US.ts index 7a2a143..ede2845 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', @@ -294,6 +295,76 @@ const enUS = { loading: 'Loading users…', empty: 'No users yet', loadMore: 'Load more', + actions: { + create: 'New user', + edit: 'Edit', + changePassword: 'Password', + lock: 'Lock', + unlock: 'Unlock', + delete: 'Delete', + }, + 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 44c3a03..31a6e2e 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: '正常', @@ -287,6 +288,75 @@ const zhCN = { loading: '正在加载用户…', empty: '暂无用户', loadMore: '加载更多', + actions: { + create: '新建用户', + edit: '编辑', + changePassword: '改密', + lock: '锁定', + unlock: '解锁', + delete: '删除', + }, + 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: { From c36361f819c9f99b2217f7170ad6bb410e37c769 Mon Sep 17 00:00:00 2001 From: ghbvf <104540935+ghbvf@users.noreply.github.com> Date: Sat, 30 May 2026 02:25:10 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(access):=20PR-10=20review=20=E2=80=94?= =?UTF-8?q?=20a11y=20=E5=AF=B9=E6=AF=94=E5=BA=A6/alertdialog/focus-trap=20?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA=20(Cx1/Cx2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6-reviewer 审查的 IN_SCOPE Cx1/Cx2 修复: - a11y 对比度(P1):新增 themed `--err-strong` token(light 0.47 / dark 0.72), 危险按钮改 `color:var(--bg)`+`bg:var(--err-strong)`(随主题反色,两套主题都过 WCAG AA),危险行操作文字改 `--err-strong`(旧 `--err` 在白底仅 ~3.3:1) - a11y(P1):ConfirmDialog role=alertdialog 补 `aria-describedby` → message; ModalShell 新增 `descriptionId` prop - ModalShell 加固:watch `flush:'post'`(消除开模一帧内 Tab 逃逸的 listener 时序 缝隙);Tab-trap 用 indexOf,panel/外部焦点(idx=-1)也环绕,焦点不再逃出; opener 用 `instanceof HTMLElement`;`els[0]!`/`els.at(-1)!` 去除 as 转型; FOCUSABLE 选择器补 summary/contenteditable - ChangePasswordModal:校验收进 computed(对齐 IdentityFormModal,去掉每次渲染 3× 重复校验) - IdentitiesView:onConfirm 改显式三分支(lock/unlock/delete),去掉 catch-all fallthrough(防未来新增 action 静默落到 remove) - IdentityFormModal:requirePasswordReset checkbox 补 id 未处理(→ 人工确认,见 PR/收尾):core 内业务 i18n(pre-existing,需 i18n 联邦 化)、ModalShell 上提 @gocell/core(文档化的"第二 cell 再提")、乐观更新替代 全量 refetch、背景 inert、tab 方向键、z-index/hover token 化、useId 多实例、 逐行 aria-label。 access 194 通过;typecheck/lint 全绿;覆盖率 ~97.4%(ModalShell 98.6%)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/ChangePasswordModal.vue | 9 ++++---- .../src/components/ConfirmDialog.spec.ts | 2 ++ .../access/src/components/ConfirmDialog.vue | 10 +++++---- .../src/components/IdentityFormModal.vue | 6 ++++- packages/access/src/components/ModalShell.vue | 22 +++++++++++++------ packages/access/src/views/IdentitiesView.vue | 6 +++-- packages/core/src/styles/tokens.css | 7 ++++++ 7 files changed, 44 insertions(+), 18 deletions(-) diff --git a/packages/access/src/components/ChangePasswordModal.vue b/packages/access/src/components/ChangePasswordModal.vue index 0f931e7..b5defcd 100644 --- a/packages/access/src/components/ChangePasswordModal.vue +++ b/packages/access/src/components/ChangePasswordModal.vue @@ -6,7 +6,7 @@ * self-service changes). Validation is inline (i18n keys via validators); a * server error stays in a live region and keeps the modal open. */ -import { reactive, ref, watch } from 'vue' +import { computed, reactive, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { isGoCellRequestError } from '@gocell/request' import { useIdentitiesStore } from '../stores/useIdentitiesStore' @@ -25,6 +25,8 @@ const submitted = ref(false) const submitting = ref(false) const errorKey = ref(null) +const errors = computed(() => validateChangePassword(form)) + watch( () => props.open, (open) => { @@ -40,15 +42,14 @@ watch( ) function fieldError(field: keyof ChangePasswordErrors): string | null { - return submitted.value ? validateChangePassword(form)[field] : null + return submitted.value ? errors.value[field] : null } async function onSubmit(): Promise { submitted.value = true errorKey.value = null if (!props.user) return - const errs = validateChangePassword(form) - if (errs.oldPassword || errs.newPassword || errs.confirm) return + if (errors.value.oldPassword || errors.value.newPassword || errors.value.confirm) return submitting.value = true try { diff --git a/packages/access/src/components/ConfirmDialog.spec.ts b/packages/access/src/components/ConfirmDialog.spec.ts index 130c2f7..a733c35 100644 --- a/packages/access/src/components/ConfirmDialog.spec.ts +++ b/packages/access/src/components/ConfirmDialog.spec.ts @@ -22,7 +22,9 @@ describe('ConfirmDialog', () => { 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 () => { diff --git a/packages/access/src/components/ConfirmDialog.vue b/packages/access/src/components/ConfirmDialog.vue index 24c8c1c..320d87e 100644 --- a/packages/access/src/components/ConfirmDialog.vue +++ b/packages/access/src/components/ConfirmDialog.vue @@ -31,11 +31,12 @@ const { t } = useI18n()

{{ t(titleKey) }}

-

{{ t(messageKey) }}

+

{{ t(messageKey) }}

@@ -111,9 +112,10 @@ const { t } = useI18n() } .confirm__btn--danger { - color: var(--bg-raised); - background: var(--err); - border-color: var(--err); + /* --bg inverts per theme so text stays high-contrast on the themed --err-strong fill */ + color: var(--bg); + background: var(--err-strong); + border-color: var(--err-strong); } .confirm__btn:disabled { diff --git a/packages/access/src/components/IdentityFormModal.vue b/packages/access/src/components/IdentityFormModal.vue index 5c0a661..b3ddefa 100644 --- a/packages/access/src/components/IdentityFormModal.vue +++ b/packages/access/src/components/IdentityFormModal.vue @@ -171,7 +171,11 @@ async function onSubmit(): Promise { diff --git a/packages/access/src/components/ModalShell.vue b/packages/access/src/components/ModalShell.vue index 951fcd4..ebc7192 100644 --- a/packages/access/src/components/ModalShell.vue +++ b/packages/access/src/components/ModalShell.vue @@ -15,6 +15,8 @@ const props = withDefaults( open: boolean /** id of the element labelling the dialog (its title). */ titleId: string + /** id of the element describing the dialog (its body) — required for alertdialog. */ + descriptionId?: string role?: 'dialog' | 'alertdialog' }>(), { role: 'dialog' }, @@ -26,7 +28,7 @@ const panel = ref(null) let opener: HTMLElement | null = null const FOCUSABLE = - 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [contenteditable]:not([contenteditable="false"]), [tabindex]:not([tabindex="-1"])' function focusables(): HTMLElement[] { if (!panel.value) return [] @@ -46,13 +48,16 @@ function onKeydown(e: KeyboardEvent): void { panel.value?.focus() return } - const first = els[0] as HTMLElement - const last = els[els.length - 1] as HTMLElement + const first = els[0]! + const last = els.at(-1)! const active = document.activeElement - if (e.shiftKey && active === first) { + // idx === -1 covers focus resting on the panel itself or outside → still wrap, + // so Tab/Shift+Tab can never escape the dialog. + const idx = active instanceof HTMLElement ? els.indexOf(active) : -1 + if (e.shiftKey && idx <= 0) { e.preventDefault() last.focus() - } else if (!e.shiftKey && active === last) { + } else if (!e.shiftKey && (idx === -1 || idx === els.length - 1)) { e.preventDefault() first.focus() } @@ -62,7 +67,7 @@ watch( () => props.open, (isOpen) => { if (isOpen) { - opener = (document.activeElement as HTMLElement | null) ?? null + opener = document.activeElement instanceof HTMLElement ? document.activeElement : null document.addEventListener('keydown', onKeydown, true) void nextTick(() => { const els = focusables() @@ -74,7 +79,9 @@ watch( opener = null } }, - { immediate: true }, + // flush: 'post' so the listener + focus run after the panel is committed to the + // DOM (otherwise a Tab in the open-tick gap would find no focusables to trap). + { immediate: true, flush: 'post' }, ) onBeforeUnmount(() => { @@ -90,6 +97,7 @@ onBeforeUnmount(() => { :role="role" aria-modal="true" :aria-labelledby="titleId" + :aria-describedby="descriptionId" tabindex="-1" > diff --git a/packages/access/src/views/IdentitiesView.vue b/packages/access/src/views/IdentitiesView.vue index 2b011ff..d940750 100644 --- a/packages/access/src/views/IdentitiesView.vue +++ b/packages/access/src/views/IdentitiesView.vue @@ -84,9 +84,11 @@ async function onConfirm(): Promise { 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 await store.remove(user.id) + else if (action === 'delete') await store.remove(user.id) confirmState.value = null } catch (err: unknown) { confirmErrorKey.value = isGoCellRequestError(err) @@ -456,7 +458,7 @@ async function onConfirm(): Promise { } .identities__action--danger { - color: var(--err); + color: var(--err-strong); } .identities__more { diff --git a/packages/core/src/styles/tokens.css b/packages/core/src/styles/tokens.css index 8dcb157..ea7b822 100644 --- a/packages/core/src/styles/tokens.css +++ b/packages/core/src/styles/tokens.css @@ -28,6 +28,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; @@ -65,6 +68,10 @@ --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); From 26451e2c2052e261e45e26341d7bcdc17a94a67c Mon Sep 17 00:00:00 2001 From: ghbvf <104540935+ghbvf@users.noreply.github.com> Date: Sat, 30 May 2026 03:19:12 +0800 Subject: [PATCH 3/4] =?UTF-8?q?refactor(access,core):=20=E5=A4=84=E7=90=86?= =?UTF-8?q?=20review=20deferred=20=E9=A1=B9=20=E2=80=94=20ModalShell=20?= =?UTF-8?q?=E4=B8=8A=E6=8F=90=20core=20+=20=E5=88=86=E9=A1=B5=E4=BF=9D?= =?UTF-8?q?=E7=95=99=20+=20a11y/token=20=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 应"能处理的就处理好",把可干净修复的 deferred 项落地(i18n 联邦化单独 PR): - [#2 架构] ModalShell 从 @gocell/access 上提到 @gocell/core/components(纯 UI 原语, 无业务);三个 modal 改 import { ModalShell } from '@gocell/core/components'; spec 一并迁移到 core。core ./components 已有 exports,无新增入口。 - [#4 a11y] ModalShell 开启时对背景兄弟节点逐级 inert(panel→body 跳过自身链路), 补 aria-modal 在非原生 dialog 下 NVDA/JAWS 虚拟光标可达背景的缺口;关闭/卸载还原。 - [#3 bug] useIdentitiesStore 新增 refreshLoaded():mutation 成功后按"已加载条数" 重取(而非塌回第 1 页),不再丢失 loadMore 展开的页;新增 100 行场景测试。 - [#5 a11y] 行操作按钮加 username 作用域 aria-label(actionLabel 助手 + access.identities.actions.rowSuffix i18n),读屏不再"编辑/编辑/编辑"。 - [#6 token] 新增 --z-modal / --fg-hover token;ModalShell z-index 与 create/ primary 按钮 hover 改用 token(去 z-index 魔法数 + oklch 字面量,PR-10 新代码)。 - [#7 复用] ConfirmDialog 加 cancelKey prop(默认 access.identities.confirm.cancel), 解除与 identities 命名空间的耦合。 未做(理由):i18n 联邦化(#1,唯一被核查判 should-fix)—— 需迁移已合并的 PR-08 login/firstRun key + 改 apps/web i18n bootstrap,属独立横切重构,单独开 PR;useId 唯一 title-id(无现存 bug、单实例,且会冲掉 4 个 spec 的 #id 选择器)。 core 193 / access 186 通过;workspace 515 通过(useTheme 为既有并发 flake,隔离通过); typecheck/lint 全绿;contracts 未触碰(codegen 另现 audit schema 上游漂移,与本 PR 无关, 属独立 codegen-sync)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/access/README.md | 7 +-- .../src/components/ChangePasswordModal.vue | 2 +- .../src/components/ConfirmDialog.spec.ts | 9 ++++ .../access/src/components/ConfirmDialog.vue | 6 ++- .../src/components/IdentityFormModal.vue | 4 +- .../src/stores/useIdentitiesStore.spec.ts | 22 ++++++++++ .../access/src/stores/useIdentitiesStore.ts | 33 +++++++++++--- .../access/src/views/IdentitiesView.spec.ts | 6 +++ packages/access/src/views/IdentitiesView.vue | 39 ++++++++++++++--- .../src/components/ModalShell.spec.ts | 18 ++++++++ .../src/components/ModalShell.vue | 43 ++++++++++++++++--- packages/core/src/components/index.ts | 1 + packages/core/src/i18n/messages/en-US.ts | 2 + packages/core/src/i18n/messages/zh-CN.ts | 2 + packages/core/src/styles/tokens.css | 6 +++ 15 files changed, 175 insertions(+), 25 deletions(-) rename packages/{access => core}/src/components/ModalShell.spec.ts (85%) rename packages/{access => core}/src/components/ModalShell.vue (69%) diff --git a/packages/access/README.md b/packages/access/README.md index 163fbf3..4d99a15 100644 --- a/packages/access/README.md +++ b/packages/access/README.md @@ -16,9 +16,10 @@ auth store(全内存 token)+ first-run / login 视图 + Identities 列表 + > 每个 view 各为独立 export 子路径 → 各自独立 async chunk(访问 `/login` 不连带加载 first-run 向导 / identities 表)。 > `api/setup`、`api/identities`、`composables/useSetupWizard`、`lib/validation`、`lib/identityValidation`、 -> `components/{IdentityStatusPill,ModalShell,IdentityFormModal,ChangePasswordModal,ConfirmDialog}.vue` -> 是包内私有模块(不在 `exports`),仅供本包 views / store 消费。`ModalShell` 是 a11y modal 原语 -> (focus-trap + ESC + 焦点回归),第二个 cell 需要时再上提到 `@gocell/core`。 +> `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 diff --git a/packages/access/src/components/ChangePasswordModal.vue b/packages/access/src/components/ChangePasswordModal.vue index b5defcd..bb2d211 100644 --- a/packages/access/src/components/ChangePasswordModal.vue +++ b/packages/access/src/components/ChangePasswordModal.vue @@ -12,7 +12,7 @@ import { isGoCellRequestError } from '@gocell/request' import { useIdentitiesStore } from '../stores/useIdentitiesStore' import type { Identity } from '../api/identities' import { validateChangePassword, type ChangePasswordErrors } from '../lib/identityValidation' -import ModalShell from './ModalShell.vue' +import { ModalShell } from '@gocell/core/components' const props = defineProps<{ open: boolean; user: Identity | null }>() const emit = defineEmits<{ close: []; saved: [] }>() diff --git a/packages/access/src/components/ConfirmDialog.spec.ts b/packages/access/src/components/ConfirmDialog.spec.ts index a733c35..5c9fa45 100644 --- a/packages/access/src/components/ConfirmDialog.spec.ts +++ b/packages/access/src/components/ConfirmDialog.spec.ts @@ -40,6 +40,15 @@ describe('ConfirmDialog', () => { 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) diff --git a/packages/access/src/components/ConfirmDialog.vue b/packages/access/src/components/ConfirmDialog.vue index 320d87e..a1939e9 100644 --- a/packages/access/src/components/ConfirmDialog.vue +++ b/packages/access/src/components/ConfirmDialog.vue @@ -7,7 +7,7 @@ * title/message labelling make it demand an explicit decision. */ import { useI18n } from 'vue-i18n' -import ModalShell from './ModalShell.vue' +import { ModalShell } from '@gocell/core/components' withDefaults( defineProps<{ @@ -19,6 +19,8 @@ withDefaults( busy?: boolean /** Server-error i18n key shown inline; keeps the dialog open on failure. */ errorKey?: string | null + /** Cancel-button i18n key — overridable so the dialog isn't bound to one cell. */ + cancelKey?: string }>(), { danger: false, busy: false, errorKey: null }, ) @@ -42,7 +44,7 @@ const { t } = useI18n()
- - - - @@ -217,6 +245,7 @@ async function onConfirm(): Promise {