Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions apps/web/src/router/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
9 changes: 5 additions & 4 deletions apps/web/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 + <Can> 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' },
},
],
},
Expand Down
16 changes: 12 additions & 4 deletions packages/access/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 分页,对齐
Expand Down Expand Up @@ -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 语义 `<table>` + status pill + 客户端筛选 + 禁用「服务账号」tab 占位(FR-030,`aria-disabled` + `tabindex="-1"`)。MVP 只读;行操作 modal + `<Can>` 见 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 语义 `<table>` + status pill + 客户端筛选 + 禁用「服务账号」tab 占位(FR-030,`aria-disabled` + `tabindex="-1"`)。行操作(create/edit/change-password/lock/unlock/delete)开 modal,每个动作按钮挂 `<Can>`(fail-closed:PDP 不允许即隐藏);路由另挂 `meta.requiredAction='read'` + `requiredResource='identity'`(guards.ts fail-closed,PDP 后端未接通前整页拒绝,见 BR-004)。
- **BR-005**:list 端点未交付,`api/identities` 用临时信封类型(见上「依赖的 contract」)。

### `createPdpClient(): PdpClient`
Expand All @@ -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`(含 `<Can>` fail-closed)、`useSetupWizard`、`LoginView`、`FirstRunSetupView`。整包 ≥ 80%(实测 ~97.5% lines)。
78 changes: 77 additions & 1 deletion packages/access/src/api/identities.spec.ts
Original file line number Diff line number Diff line change
@@ -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: [
Expand Down Expand Up @@ -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()
})
})
51 changes: 48 additions & 3 deletions packages/access/src/api/identities.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -42,3 +52,38 @@ export async function listUsers(params: ListUsersParams = {}): Promise<UserListP
const res = await http.get<UserListPage>(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<void> {
await http.post(USERS_URL, body)
}

/** PATCH /users/{id} — partial edit (email / status / requirePasswordReset). */
export async function patchUser(id: string, body: HttpAuthUserPatchV1Request): Promise<void> {
await http.patch(userUrl(id), body)
}

/** DELETE /users/{id}. */
export async function deleteUser(id: string): Promise<void> {
await http.delete(userUrl(id))
}

/** POST /users/{id}/lock. */
export async function lockUser(id: string): Promise<void> {
await http.post(`${userUrl(id)}/lock`)
}

/** POST /users/{id}/unlock. */
export async function unlockUser(id: string): Promise<void> {
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<void> {
await http.post(`${userUrl(id)}/password`, body)
}
84 changes: 84 additions & 0 deletions packages/access/src/components/ChangePasswordModal.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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<typeof mountModal>['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()
})
})
Loading
Loading