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 @@
+
+ {{ item.namespace }}
+
+
+ {{ item.tags?.join(', ') }}
+
+
+
@@ -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()
}
}