diff --git a/.yarn/versions/9abdd00c.yml b/.yarn/versions/9abdd00c.yml new file mode 100644 index 0000000..85ee0e5 --- /dev/null +++ b/.yarn/versions/9abdd00c.yml @@ -0,0 +1,3 @@ +releases: + "@aoi-js/frontend": patch + "@aoi-js/server": patch diff --git a/apps/frontend/src/pages/admin/user.vue b/apps/frontend/src/pages/admin/user.vue index c48cb57..145968f 100644 --- a/apps/frontend/src/pages/admin/user.vue +++ b/apps/frontend/src/pages/admin/user.vue @@ -25,12 +25,25 @@ + + @@ -51,18 +64,27 @@ import AppGravatar from '@/components/app/AppGravatar.vue' import CapabilityChips from '@/components/utils/CapabilityChips.vue' import CapabilityInput from '@/components/utils/CapabilityInput.vue' +import { useAppState } from '@/stores/app' +import { useAsyncTask } from '@/utils/async' import { userBits } from '@/utils/capability' -import { http } from '@/utils/http' +import { http, login, logout } from '@/utils/http' import { usePagination } from '@/utils/pagination' +import { useRouteQuery } from '@vueuse/router' +import { computed } from 'vue' import { ref } from 'vue' +import { useRouter } from 'vue-router' const headers = [ { title: 'Profile', key: 'profile', align: 'start', sortable: false }, { title: 'ID', key: '_id' }, { title: 'Capabilities', key: '_cap' }, + { title: 'Namespace', key: '_namespace' }, + { title: 'Tags', key: '_tags' }, { title: 'Actions', key: '_actions' } ] as const +const search = useRouteQuery('search', '') + const { page, itemsPerPage, @@ -74,7 +96,12 @@ const { email: string } capability?: string -}>(`admin/user`, {}) + namespace?: string + tags?: string[] +}>( + `admin/user`, + computed(() => ({ search: search.value || undefined })) +) const dialog = ref(false) const dialogUserId = ref('') @@ -93,4 +120,16 @@ async function updatePrincipal() { }) users.execute(0, page.value, itemsPerPage.value) } + +const router = useRouter() +const appState = useAppState() + +const loginAs = useAsyncTask(async (userId: string) => { + const { token } = await http + .post(`admin/user/login`, { json: { userId } }) + .json<{ token: string }>() + await router.replace('/') + logout() + login(token) +}) diff --git a/apps/frontend/src/utils/pagination.ts b/apps/frontend/src/utils/pagination.ts index 03cdbc7..3d5fb4b 100644 --- a/apps/frontend/src/utils/pagination.ts +++ b/apps/frontend/src/utils/pagination.ts @@ -13,6 +13,10 @@ function shallowEqual(a: Record, b: Record) { return true } +function removeUndefined(obj: Record) { + return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) +} + export function usePagination( endpoint: MaybeRef, params: MaybeRef> @@ -30,7 +34,7 @@ export function usePagination( page: page, perPage: itemsPerPage, count: cachedCount === -1, - ...paramsRef.value + ...removeUndefined(paramsRef.value) } }) .json<{ items: T[]; total: number }>() diff --git a/apps/server/package.json b/apps/server/package.json index d3f2d67..60365b4 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -8,9 +8,7 @@ "license": "AGPL-3.0-only", "main": "lib/index.js", "files": [ - "lib", - "!lib/**/*.map", - "!lib/**/*.d.ts" + "lib" ], "bin": { "aoi-server": "lib/cli/index.js" diff --git a/apps/server/src/auth/password.ts b/apps/server/src/auth/password.ts index 6f565e7..0b8cebb 100644 --- a/apps/server/src/auth/password.ts +++ b/apps/server/src/auth/password.ts @@ -66,7 +66,7 @@ export class PasswordAuthProvider extends BaseAuthProvider { if (!PasswordLoginPayload.Check(payload)) throw new Error('invalid payload') const { username, password } = payload const user = await users.findOne( - { 'profile.name': username }, + { namespace: { $exists: false }, 'profile.name': username }, { projection: { _id: 1, authSources: 1 } } ) if (!user || !user.authSources.password) throw new Error('user not found') diff --git a/apps/server/src/db/user.ts b/apps/server/src/db/user.ts index 2ae83a9..b365344 100644 --- a/apps/server/src/db/user.ts +++ b/apps/server/src/db/user.ts @@ -21,7 +21,9 @@ export interface IUser { profile: IUserProfile authSources: IUserAuthSources capability?: BSON.Long + namespace?: string + tags?: string[] } export const users = db.collection('users') -await users.createIndex({ 'profile.name': 1 }, { unique: true }) +await users.createIndex({ namespace: 1, 'profile.name': 1 }, { unique: true }) diff --git a/apps/server/src/routes/admin/user.ts b/apps/server/src/routes/admin/user.ts index c95ff0d..a610abd 100644 --- a/apps/server/src/routes/admin/user.ts +++ b/apps/server/src/routes/admin/user.ts @@ -1,7 +1,7 @@ import { Type } from '@sinclair/typebox' import { defineRoutes } from '../common/index.js' -import { BSON, Document } from 'mongodb' -import { findPaginated, users } from '../../index.js' +import { BSON, Document, UUID } from 'mongodb' +import { SUserProfile, findPaginated, users } from '../../index.js' export const adminUserRoutes = defineRoutes(async (s) => { s.get( @@ -63,4 +63,51 @@ export const adminUserRoutes = defineRoutes(async (s) => { return {} } ) + + s.post( + '/', + { + schema: { + description: 'Manually create a user', + body: Type.StrictObject({ + profile: SUserProfile, + capability: Type.Optional(Type.String()), + namespace: Type.Optional(Type.String()), + tags: Type.Optional(Type.Array(Type.String())) + }), + response: { + 200: Type.Object({ + userId: Type.String() + }) + } + } + }, + async (req) => { + const { insertedId } = await users.insertOne({ + ...req.body, + _id: new UUID(), + authSources: {}, + capability: req.body.capability ? new BSON.Long(req.body.capability) : undefined + }) + return { userId: insertedId.toString() } + } + ) + + s.post( + '/login', + { + schema: { + description: 'Login as given user, bypassing authentication', + body: Type.Object({ + userId: Type.UUID(), + tags: Type.Optional(Type.Array(Type.String())) + }) + } + }, + async (req, rep) => { + const { userId, tags } = req.body + const token = await rep.jwtSign({ userId: userId.toString(), tags }, { expiresIn: '7d' }) + return { token } + } + ) }) diff --git a/apps/server/src/routes/public/index.ts b/apps/server/src/routes/public/index.ts index ef2c424..bebb436 100644 --- a/apps/server/src/routes/public/index.ts +++ b/apps/server/src/routes/public/index.ts @@ -23,7 +23,9 @@ export const publicRoutes = defineRoutes(async (s) => { principalId: Type.UUID(), principalType: Type.StringEnum(['user', 'group']), name: Type.String(), - emailHash: Type.String() + emailHash: Type.String(), + namespace: Type.Optional(Type.String()), + tags: Type.Optional(Type.Array(Type.String())) }), { maxItems: 50 } ) @@ -38,7 +40,9 @@ export const publicRoutes = defineRoutes(async (s) => { { projection: { 'profile.name': 1, - 'profile.email': 1 + 'profile.email': 1, + namespace: 1, + tags: 1 } } ) @@ -55,11 +59,13 @@ export const publicRoutes = defineRoutes(async (s) => { ) .toArray() const result = [ - ...matchedUsers.map(({ _id, profile: { name, email } }) => ({ + ...matchedUsers.map(({ _id, profile: { name, email }, namespace, tags }) => ({ principalId: _id, principalType: 'user' as const, name, - emailHash: md5(email) + emailHash: md5(email), + namespace, + tags })), ...matchedGroups.map(({ _id, profile: { name, email } }) => ({ principalId: _id, diff --git a/apps/server/src/routes/user/scoped.ts b/apps/server/src/routes/user/scoped.ts index 717ef8d..094bfb3 100644 --- a/apps/server/src/routes/user/scoped.ts +++ b/apps/server/src/routes/user/scoped.ts @@ -32,7 +32,9 @@ export const userScopedRoutes = defineRoutes(async (s) => { response: { 200: Type.Object({ profile: SUserProfile, - capability: Type.Optional(Type.String()) + capability: Type.Optional(Type.String()), + namespace: Type.Optional(Type.String()), + tags: Type.Optional(Type.Array(Type.String())) }) } } @@ -40,11 +42,11 @@ export const userScopedRoutes = defineRoutes(async (s) => { async (req, rep) => { const user = await users.findOne( { _id: req.inject(kUserContext)._userId }, - { projection: { profile: 1, capability: 1 } } + { projection: { profile: 1, capability: 1, namespace: 1, tags: 1 } } ) if (!user) return rep.notFound() return { - profile: user.profile, + ...user, capability: user.capability?.toString() } }