@axium/server 0.37.2 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acl.d.ts +8 -2
- package/dist/acl.js +17 -19
- package/dist/api/admin.js +7 -7
- package/dist/auth.js +34 -27
- package/dist/database.d.ts +1 -1
- package/package.json +3 -3
- package/routes/account/+page.svelte +41 -34
- package/routes/admin/+page.svelte +22 -17
- package/routes/admin/audit/+page.svelte +25 -24
- package/routes/admin/audit/[id]/+page.svelte +14 -13
- package/routes/admin/config/+page.svelte +4 -3
- package/routes/admin/plugins/+page.svelte +17 -9
- package/routes/admin/users/+page.svelte +17 -16
- package/routes/admin/users/[id]/+page.svelte +28 -25
- package/routes/login/+page.svelte +2 -1
- package/routes/login/client/+page.svelte +8 -7
- package/routes/login/token/+page.svelte +2 -1
- package/routes/logout/+page.svelte +2 -1
- package/routes/register/+page.svelte +2 -1
package/dist/acl.d.ts
CHANGED
|
@@ -2,11 +2,14 @@ import { type AccessControl, type AccessControllable, type AccessTarget, type Us
|
|
|
2
2
|
import type * as kysely from 'kysely';
|
|
3
3
|
import type { WithRequired } from 'utilium';
|
|
4
4
|
import * as db from './database.js';
|
|
5
|
+
export interface DBAccessControllable extends Omit<AccessControllable, 'id'> {
|
|
6
|
+
id: string | kysely.Generated<string>;
|
|
7
|
+
}
|
|
5
8
|
type _TableNames = keyof {
|
|
6
9
|
[K in keyof db.Schema as db.Schema[K] extends db.DBAccessControl ? K : never]: null;
|
|
7
10
|
};
|
|
8
11
|
type _TargetNames = keyof db.Schema & keyof {
|
|
9
|
-
[K in keyof db.Schema as db.Schema[K] extends
|
|
12
|
+
[K in keyof db.Schema as db.Schema[K] extends DBAccessControllable ? `acl.${K}` extends keyof db.Schema ? K : never : never]: null;
|
|
10
13
|
};
|
|
11
14
|
/**
|
|
12
15
|
* `never` causes a ton of problems, so we use `string` if none of the tables are shareable.
|
|
@@ -28,16 +31,19 @@ export interface ACLSelectionOptions {
|
|
|
28
31
|
/** Instead of using the `id` from `table`, use the `id` from this instead */
|
|
29
32
|
alias?: string;
|
|
30
33
|
}
|
|
34
|
+
/** Match ACL entries, optionally selecting for a given user-like object */
|
|
35
|
+
export declare function match(user?: Pick<UserInternal, 'id' | 'roles' | 'tags'>): (eb: kysely.ExpressionBuilder<db.Schema, any>) => kysely.ExpressionWrapper<db.Schema, any, kysely.SqlBool>;
|
|
31
36
|
/**
|
|
32
37
|
* Helper to select all access controls for a given table, including the user information.
|
|
33
38
|
* Optionally filter for the entries applicable to a specific user.
|
|
34
39
|
* This includes entries matching the user's ID, roles, or tags along with the "public" entry where all three "target" columns are null.
|
|
35
40
|
*/
|
|
36
|
-
export declare function from<const TB extends TargetName>(table: TB, opt?: ACLSelectionOptions): (eb: kysely.ExpressionBuilder<
|
|
41
|
+
export declare function from<const TB extends TargetName, const DB = db.Schema>(table: TB, opt?: ACLSelectionOptions): (eb: kysely.ExpressionBuilder<DB, any>) => kysely.AliasedRawBuilder<Result<`acl.${TB}`>[], 'acl'>;
|
|
37
42
|
export declare function get<const TB extends TableName>(table: TB, itemId: string): Promise<WithRequired<Result<TB>, 'user'>[]>;
|
|
38
43
|
export declare function update<const TB extends TableName>(table: TB, itemId: string, target: AccessTarget, permissions: PermissionsFor<TB>): Promise<Result<TB>>;
|
|
39
44
|
export declare function remove<const TB extends TableName>(table: TB, itemId: string, target: AccessTarget): Promise<Result<TB>>;
|
|
40
45
|
export declare function add<const TB extends TableName>(table: TB, itemId: string, target: AccessTarget): Promise<Result<TB>>;
|
|
46
|
+
/** Check an ACL against a set of permissions. */
|
|
41
47
|
export declare function check<const TB extends TableName>(acl: Result<TB>[], permissions: Partial<PermissionsFor<TB>>): Set<keyof PermissionsFor<TB>>;
|
|
42
48
|
export declare function listTables(): Record<string, TableName>;
|
|
43
49
|
export interface OptionsForWhere<TB extends TargetName> {
|
package/dist/acl.js
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import { fromTarget } from '@axium/core';
|
|
2
2
|
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
|
3
3
|
import * as db from './database.js';
|
|
4
|
+
/** Match ACL entries, optionally selecting for a given user-like object */
|
|
5
|
+
export function match(user) {
|
|
6
|
+
return function (eb) {
|
|
7
|
+
const allNull = eb.and([eb('userId', 'is', null), eb('role', 'is', null), eb('tag', 'is', null)]);
|
|
8
|
+
if (!user)
|
|
9
|
+
return allNull;
|
|
10
|
+
const ors = [allNull, eb('userId', '=', user.id)];
|
|
11
|
+
if (user.roles.length)
|
|
12
|
+
ors.push(eb('role', 'in', user.roles));
|
|
13
|
+
if (user.tags.length)
|
|
14
|
+
ors.push(eb('tag', 'in', user.tags));
|
|
15
|
+
return eb.or(ors);
|
|
16
|
+
};
|
|
17
|
+
}
|
|
4
18
|
/**
|
|
5
19
|
* Helper to select all access controls for a given table, including the user information.
|
|
6
20
|
* Optionally filter for the entries applicable to a specific user.
|
|
@@ -12,17 +26,7 @@ export function from(table, opt = {}) {
|
|
|
12
26
|
.selectAll()
|
|
13
27
|
.$if(!opt.user, qb => qb.select(db.userFromId))
|
|
14
28
|
.whereRef('_acl.itemId', '=', `${opt.alias || table}.id`)
|
|
15
|
-
|
|
16
|
-
const allNull = eb.and([eb('userId', 'is', null), eb('role', 'is', null), eb('tag', 'is', null)]);
|
|
17
|
-
if (!opt.user)
|
|
18
|
-
return allNull;
|
|
19
|
-
const ors = [allNull, eb('userId', '=', opt.user.id)];
|
|
20
|
-
if (opt.user.roles.length)
|
|
21
|
-
ors.push(eb('role', 'in', opt.user.roles));
|
|
22
|
-
if (opt.user.tags.length)
|
|
23
|
-
ors.push(eb('tag', 'in', opt.user.tags));
|
|
24
|
-
return eb.or(ors);
|
|
25
|
-
})))
|
|
29
|
+
.where(match(opt.user)))
|
|
26
30
|
.$castTo()
|
|
27
31
|
.as('acl');
|
|
28
32
|
}
|
|
@@ -62,6 +66,7 @@ export async function add(table, itemId, target) {
|
|
|
62
66
|
.$castTo()
|
|
63
67
|
.executeTakeFirstOrThrow();
|
|
64
68
|
}
|
|
69
|
+
/** Check an ACL against a set of permissions. */
|
|
65
70
|
export function check(acl, permissions) {
|
|
66
71
|
const allowed = new Set();
|
|
67
72
|
const all = new Set(Object.keys(permissions));
|
|
@@ -91,14 +96,7 @@ export function existsIn(table, user, options = {}) {
|
|
|
91
96
|
.selectFrom(`acl.${table}`)
|
|
92
97
|
// @ts-expect-error 2349
|
|
93
98
|
.whereRef('itemId', '=', `${options.alias || `public.${table}`}.${options.itemId || 'id'}`)
|
|
94
|
-
.where((
|
|
95
|
-
const ors = [eb('userId', '=', user.id)];
|
|
96
|
-
if (user.roles.length)
|
|
97
|
-
ors.push(eb('role', 'in', user.roles));
|
|
98
|
-
if (user.tags.length)
|
|
99
|
-
ors.push(eb('tag', 'in', user.tags));
|
|
100
|
-
return eb.or(ors);
|
|
101
|
-
}));
|
|
99
|
+
.where(match(user)));
|
|
102
100
|
}
|
|
103
101
|
/**
|
|
104
102
|
* Use in a `where` to filter by items a user has access to
|
package/dist/api/admin.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import client from '@axium/client/package.json' with { type: 'json' };
|
|
1
2
|
import { AuditFilter, Severity, UserAdminChange } from '@axium/core';
|
|
2
3
|
import { debug, errorText, writeJSON } from '@axium/core/node/io';
|
|
3
|
-
import
|
|
4
|
+
import core from '@axium/core/package.json' with { type: 'json' };
|
|
4
5
|
import { _findPlugin, plugins, PluginUpdate, serverConfigs } from '@axium/core/plugins';
|
|
5
6
|
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
|
6
7
|
import { mkdirSync } from 'node:fs';
|
|
7
8
|
import { dirname } from 'node:path/posix';
|
|
8
9
|
import { deepAssign, omit } from 'utilium';
|
|
9
10
|
import * as z from 'zod';
|
|
11
|
+
import $pkg from '../../package.json' with { type: 'json' };
|
|
10
12
|
import { audit, events, getEvents } from '../audit.js';
|
|
11
13
|
import { createVerification, requireSession } from '../auth.js';
|
|
12
14
|
import { config } from '../config.js';
|
|
@@ -40,9 +42,9 @@ addRoute({
|
|
|
40
42
|
configFiles: config.files.size,
|
|
41
43
|
plugins: plugins.size,
|
|
42
44
|
versions: {
|
|
43
|
-
server:
|
|
44
|
-
core:
|
|
45
|
-
client:
|
|
45
|
+
server: $pkg.version,
|
|
46
|
+
core: core.version,
|
|
47
|
+
client: client.version,
|
|
46
48
|
},
|
|
47
49
|
};
|
|
48
50
|
},
|
|
@@ -51,9 +53,7 @@ addRoute({
|
|
|
51
53
|
path: '/api/admin/plugins',
|
|
52
54
|
async GET(req) {
|
|
53
55
|
await assertAdmin(this, req);
|
|
54
|
-
return await Array.fromAsync(plugins
|
|
55
|
-
.values()
|
|
56
|
-
.map(async (p) => Object.assign(omit(p, '_hooks', '_client'), p.update_checks ? await getVersionInfo(p.specifier, p.loadedBy) : { latest: null })));
|
|
56
|
+
return await Array.fromAsync(plugins.values().map(p => omit(p, '_hooks', '_client')));
|
|
57
57
|
},
|
|
58
58
|
async POST(req) {
|
|
59
59
|
await assertAdmin(this, req);
|
package/dist/auth.js
CHANGED
|
@@ -118,7 +118,7 @@ export async function authSessionForItem(itemType, itemId, permissions, session,
|
|
|
118
118
|
.selectFrom(itemType)
|
|
119
119
|
.selectAll()
|
|
120
120
|
.where('id', '=', itemId)
|
|
121
|
-
|
|
121
|
+
.select(acl.from(itemType, { user }))
|
|
122
122
|
.executeTakeFirstOrThrow()
|
|
123
123
|
.catch(e => {
|
|
124
124
|
if (e.message.includes('no rows'))
|
|
@@ -138,33 +138,40 @@ export async function authSessionForItem(itemType, itemId, permissions, session,
|
|
|
138
138
|
if (userId == item.userId)
|
|
139
139
|
return result;
|
|
140
140
|
result.fromACL = true;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
141
|
+
const matchingControls = recursive
|
|
142
|
+
? await db
|
|
143
|
+
.withRecursive('parents', qc => qc.selectFrom(itemType)
|
|
144
|
+
.select(['id', 'parentId'])
|
|
145
|
+
.$castTo()
|
|
146
|
+
.select(acl.from(itemType, { user }))
|
|
147
|
+
.select(eb => eb.lit(0).as('depth'))
|
|
148
|
+
.where('id', '=', itemId)
|
|
149
|
+
.unionAll(qc.selectFrom(`${itemType} as item`)
|
|
150
|
+
.select(['item.id', 'item.parentId'])
|
|
151
|
+
.innerJoin('parents as p', 'item.id', 'p.parentId')
|
|
152
|
+
.select(eb => eb(eb.ref('p.depth'), '+', eb.lit(1)).as('depth'))
|
|
153
|
+
.select(acl.from(itemType, { user, alias: 'item' }))))
|
|
154
|
+
.selectFrom('parents')
|
|
155
|
+
.select('acl')
|
|
156
|
+
.execute()
|
|
157
|
+
.then(parents => parents.flatMap(p => p.acl))
|
|
158
|
+
.catch(e => {
|
|
159
|
+
if (!(e instanceof Error))
|
|
153
160
|
throw e;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
161
|
+
switch (e.message) {
|
|
162
|
+
case 'column "parentId" does not exist':
|
|
163
|
+
error(500, `${itemType} does not support recursive ACLs`);
|
|
164
|
+
default:
|
|
165
|
+
throw e;
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
: item.acl;
|
|
169
|
+
if (!matchingControls.length)
|
|
170
|
+
error(403, 'Item is not shared with you');
|
|
171
|
+
const missing = Array.from(acl.check(matchingControls, permissions));
|
|
172
|
+
if (missing.length)
|
|
173
|
+
error(403, 'Missing permissions: ' + missing.join(', '));
|
|
174
|
+
return result;
|
|
168
175
|
}
|
|
169
176
|
/**
|
|
170
177
|
* Authenticate a request against an "item" which has an ACL table.
|
package/dist/database.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type TablesMatching<T> = (string & keyof Schema) & keyof {
|
|
|
29
29
|
*/
|
|
30
30
|
export declare function userFromId<TB extends TablesMatching<{
|
|
31
31
|
userId: string;
|
|
32
|
-
}
|
|
32
|
+
}>, const DB extends Schema = Schema>(builder: kysely.ExpressionBuilder<DB, TB>): kysely.AliasedRawBuilder<UserInternal, 'user' | TB>;
|
|
33
33
|
/**
|
|
34
34
|
* Used for `update ... set ... from`
|
|
35
35
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axium/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"author": "James Prevett <axium@jamespre.dev>",
|
|
5
5
|
"funding": {
|
|
6
6
|
"type": "individual",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"clean": "rm -rf build .svelte-kit node_modules/{.vite,.vite-temp}"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@axium/client": ">=0.
|
|
51
|
-
"@axium/core": ">=0.
|
|
50
|
+
"@axium/client": ">=0.17.0",
|
|
51
|
+
"@axium/core": ">=0.21.0",
|
|
52
52
|
"kysely": "^0.28.0",
|
|
53
53
|
"utilium": "^2.6.0",
|
|
54
54
|
"zod": "^4.0.5"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { fetchAPI, text } from '@axium/client';
|
|
2
3
|
import { ClipboardCopy, FormDialog, Icon, Logout, SessionList, ZodForm } from '@axium/client/components';
|
|
3
|
-
import { fetchAPI } from '@axium/client/requests';
|
|
4
4
|
import '@axium/client/styles/account';
|
|
5
5
|
import { createPasskey, deletePasskey, deleteUser, sendVerificationEmail, updatePasskey, updateUser } from '@axium/client/user';
|
|
6
6
|
import { preferenceLabels, Preferences } from '@axium/core/preferences';
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
</script>
|
|
24
24
|
|
|
25
25
|
<svelte:head>
|
|
26
|
-
<title>
|
|
26
|
+
<title>{text('page.account.title')}</title>
|
|
27
27
|
</svelte:head>
|
|
28
28
|
|
|
29
29
|
{#snippet action(name: string, i: string = 'pen')}
|
|
@@ -34,95 +34,101 @@
|
|
|
34
34
|
|
|
35
35
|
<div class="Account flex-content">
|
|
36
36
|
<div id="pfp-container">
|
|
37
|
-
<img id="pfp" src={getUserImage(user)} alt=
|
|
37
|
+
<img id="pfp" src={getUserImage(user)} alt={text('page.account.profile_alt')} width="100px" height="100px" />
|
|
38
38
|
</div>
|
|
39
|
-
<p class="greeting">
|
|
39
|
+
<p class="greeting">{text('page.account.greeting', { name: user.name })}</p>
|
|
40
40
|
|
|
41
41
|
<div id="info" class="section main">
|
|
42
|
-
<h3>
|
|
42
|
+
<h3>{text('page.account.personal_info')}</h3>
|
|
43
43
|
<div class="item info">
|
|
44
|
-
<p class="subtle">
|
|
44
|
+
<p class="subtle">{text('generic.username')}</p>
|
|
45
45
|
<p>{user.name}</p>
|
|
46
46
|
{@render action('edit_name')}
|
|
47
47
|
</div>
|
|
48
|
-
<FormDialog id="edit_name" submit={_editUser} submitText=
|
|
48
|
+
<FormDialog id="edit_name" submit={_editUser} submitText={text('generic.change')}>
|
|
49
49
|
<div>
|
|
50
|
-
<label for="name">
|
|
50
|
+
<label for="name">{text('page.account.edit_name')}</label>
|
|
51
51
|
<input name="name" type="text" value={user.name || ''} required />
|
|
52
52
|
</div>
|
|
53
53
|
</FormDialog>
|
|
54
54
|
<div class="item info">
|
|
55
|
-
<p class="subtle">
|
|
55
|
+
<p class="subtle">{text('generic.email')}</p>
|
|
56
56
|
<p>
|
|
57
57
|
{user.email}
|
|
58
58
|
{#if user.emailVerified}
|
|
59
|
-
<dfn title=
|
|
59
|
+
<dfn title={text('page.account.email_verified_on', { date: user.emailVerified.toLocaleDateString() })}>
|
|
60
60
|
<Icon i="regular/circle-check" />
|
|
61
61
|
</dfn>
|
|
62
62
|
{:else if canVerify}
|
|
63
63
|
<button onclick={() => sendVerificationEmail(user.id).then(() => (verificationSent = true))}>
|
|
64
|
-
{verificationSent ? '
|
|
64
|
+
{verificationSent ? text('page.account.verification_sent') : text('page.account.verify')}
|
|
65
65
|
</button>
|
|
66
66
|
{/if}
|
|
67
67
|
</p>
|
|
68
68
|
{@render action('edit_email')}
|
|
69
69
|
</div>
|
|
70
|
-
<FormDialog id="edit_email" submit={_editUser} submitText=
|
|
70
|
+
<FormDialog id="edit_email" submit={_editUser} submitText={text('generic.change')}>
|
|
71
71
|
<div>
|
|
72
|
-
<label for="email">
|
|
72
|
+
<label for="email">{text('page.account.edit_email')}</label>
|
|
73
73
|
<input name="email" type="email" value={user.email || ''} required />
|
|
74
74
|
</div>
|
|
75
75
|
</FormDialog>
|
|
76
76
|
|
|
77
77
|
<div class="item info">
|
|
78
|
-
<p class="subtle">
|
|
78
|
+
<p class="subtle">
|
|
79
|
+
{text('page.account.user_id')} <dfn title={text('page.account.user_id_hint')}><Icon i="regular/circle-info" /></dfn>
|
|
80
|
+
</p>
|
|
79
81
|
<p>{user.id}</p>
|
|
80
82
|
<ClipboardCopy value={user.id} --size="16px" />
|
|
81
83
|
</div>
|
|
82
84
|
<div class="inline-button-container">
|
|
83
|
-
<button command="show-modal" commandfor="logout" class="inline-button
|
|
84
|
-
<button command="show-modal" commandfor="delete" class="inline-button danger">
|
|
85
|
+
<button command="show-modal" commandfor="logout" class="inline-button logout">{text('generic.logout')}</button>
|
|
86
|
+
<button command="show-modal" commandfor="delete" class="inline-button danger">{text('page.account.delete_account')}</button>
|
|
85
87
|
<Logout />
|
|
86
88
|
<FormDialog
|
|
87
89
|
id="delete"
|
|
88
90
|
submit={() => deleteUser(user.id).then(() => (window.location.href = '/'))}
|
|
89
|
-
submitText=
|
|
91
|
+
submitText={text('page.account.delete_account')}
|
|
90
92
|
submitDanger
|
|
91
93
|
>
|
|
92
|
-
<p>
|
|
94
|
+
<p>{text('page.account.delete_account_confirm')}<br />{text('generic.action_irreversible')}</p>
|
|
93
95
|
</FormDialog>
|
|
94
96
|
</div>
|
|
95
97
|
</div>
|
|
96
98
|
|
|
97
99
|
<div id="passkeys" class="section main">
|
|
98
|
-
<h3>
|
|
100
|
+
<h3>{text('page.account.passkeys.title')}</h3>
|
|
99
101
|
{#each passkeys as passkey}
|
|
100
102
|
<div class="item passkey">
|
|
101
103
|
<p>
|
|
102
|
-
<dfn
|
|
104
|
+
<dfn
|
|
105
|
+
title={passkey.deviceType == 'multiDevice'
|
|
106
|
+
? text('page.account.passkeys.multi_device')
|
|
107
|
+
: text('page.account.passkeys.single_device')}
|
|
108
|
+
>
|
|
103
109
|
<Icon i={passkey.deviceType == 'multiDevice' ? 'laptop-mobile' : 'mobile'} --size="16px" />
|
|
104
110
|
</dfn>
|
|
105
|
-
<dfn title=
|
|
111
|
+
<dfn title={passkey.backedUp ? text('page.account.passkeys.backed_up') : text('page.account.passkeys.not_backed_up')}>
|
|
106
112
|
<Icon i={passkey.backedUp ? 'circle-check' : 'circle-xmark'} --size="16px" />
|
|
107
113
|
</dfn>
|
|
108
114
|
{#if passkey.name}
|
|
109
115
|
<p>{passkey.name}</p>
|
|
110
116
|
{:else}
|
|
111
|
-
<p class="subtle"><i>
|
|
117
|
+
<p class="subtle"><i>{text('generic.unnamed')}</i></p>
|
|
112
118
|
{/if}
|
|
113
119
|
</p>
|
|
114
|
-
<p>
|
|
120
|
+
<p>{text('page.account.passkeys.created', { date: passkey.createdAt.toLocaleString() })}</p>
|
|
115
121
|
<button commandfor="edit_passkey:{passkey.id}" command="show-modal" class="icon-text">
|
|
116
122
|
<Icon i="pen" --size="16px" />
|
|
117
|
-
<span class="mobile-only">
|
|
123
|
+
<span class="mobile-only">{text('page.account.passkeys.rename')}</span>
|
|
118
124
|
</button>
|
|
119
125
|
{#if passkeys.length > 1}
|
|
120
126
|
<button commandfor="delete_passkey:{passkey.id}" command="show-modal" class="icon-text">
|
|
121
127
|
<Icon i="trash" --size="16px" />
|
|
122
|
-
<span class="mobile-only">
|
|
128
|
+
<span class="mobile-only">{text('page.account.passkeys.delete')}</span>
|
|
123
129
|
</button>
|
|
124
130
|
{:else}
|
|
125
|
-
<dfn title=
|
|
131
|
+
<dfn title={text('page.account.passkeys.min_one')} class="disabled icon-text mobile-hide">
|
|
126
132
|
<Icon i="trash-slash" --fill="#888" --size="16px" />
|
|
127
133
|
</dfn>
|
|
128
134
|
{/if}
|
|
@@ -130,39 +136,40 @@
|
|
|
130
136
|
<FormDialog
|
|
131
137
|
id={'edit_passkey:' + passkey.id}
|
|
132
138
|
submit={data => {
|
|
133
|
-
if (typeof data.name != 'string') throw '
|
|
139
|
+
if (typeof data.name != 'string') throw text('page.account.passkeys.name_type_error');
|
|
134
140
|
passkey.name = data.name;
|
|
135
141
|
return updatePasskey(passkey.id, data);
|
|
136
142
|
}}
|
|
137
|
-
submitText=
|
|
143
|
+
submitText={text('generic.change')}
|
|
138
144
|
>
|
|
139
145
|
<div>
|
|
140
|
-
<label for="name">
|
|
146
|
+
<label for="name">{text('page.account.passkeys.edit_name')}</label>
|
|
141
147
|
<input name="name" type="text" value={passkey.name || ''} />
|
|
142
148
|
</div>
|
|
143
149
|
</FormDialog>
|
|
144
150
|
<FormDialog
|
|
145
151
|
id={'delete_passkey:' + passkey.id}
|
|
146
152
|
submit={() => deletePasskey(passkey.id).then(() => passkeys.splice(passkeys.indexOf(passkey), 1))}
|
|
147
|
-
submitText=
|
|
153
|
+
submitText={text('page.account.passkeys.delete')}
|
|
148
154
|
submitDanger={true}
|
|
149
155
|
>
|
|
150
|
-
<p>
|
|
156
|
+
<p>{text('page.account.passkeys.delete_confirm')}<br />{text('generic.action_irreversible')}</p>
|
|
151
157
|
</FormDialog>
|
|
152
158
|
{/each}
|
|
153
159
|
|
|
154
160
|
<button onclick={() => createPasskey(user.id).then(passkeys.push.bind(passkeys))} class="inline-button icon-text">
|
|
155
|
-
<Icon i="plus" />
|
|
161
|
+
<Icon i="plus" />
|
|
162
|
+
{text('page.account.passkeys.create')}
|
|
156
163
|
</button>
|
|
157
164
|
</div>
|
|
158
165
|
|
|
159
166
|
<div id="sessions" class="section main">
|
|
160
|
-
<h3>
|
|
167
|
+
<h3>{text('page.account.sessions')}</h3>
|
|
161
168
|
<SessionList {sessions} {currentSession} {user} redirectAfterLogoutAll />
|
|
162
169
|
</div>
|
|
163
170
|
|
|
164
171
|
<div id="preferences" class="section main">
|
|
165
|
-
<h3>
|
|
172
|
+
<h3>{text('page.account.preferences')}</h3>
|
|
166
173
|
<ZodForm
|
|
167
174
|
bind:rootValue={user.preferences}
|
|
168
175
|
idPrefix="preferences"
|
|
@@ -1,40 +1,45 @@
|
|
|
1
|
-
<script>
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { text } from '@axium/client';
|
|
2
3
|
import { Version } from '@axium/client/components';
|
|
3
4
|
import { Severity } from '@axium/core';
|
|
4
|
-
import {
|
|
5
|
+
import { getPackage } from '@axium/core/packages';
|
|
6
|
+
import { _throw, capitalize } from 'utilium';
|
|
5
7
|
|
|
6
8
|
const { data } = $props();
|
|
9
|
+
const packages = ['server', 'core', 'client'] as const;
|
|
7
10
|
</script>
|
|
8
11
|
|
|
9
12
|
<svelte:head>
|
|
10
|
-
<title>
|
|
13
|
+
<title>{text('page.admin.dashboard.title')}</title>
|
|
11
14
|
</svelte:head>
|
|
12
15
|
|
|
13
|
-
<h2>
|
|
16
|
+
<h2>{text('page.admin.heading')}</h2>
|
|
14
17
|
|
|
15
|
-
{#each
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
{#each packages as name}
|
|
19
|
+
<p>
|
|
20
|
+
Axium {capitalize(name)}
|
|
21
|
+
<Version v={data.versions[name]} latest={getPackage('@axium/' + name).then(pkg => pkg?._latest || _throw(null))} />
|
|
22
|
+
</p>
|
|
18
23
|
{/each}
|
|
19
24
|
|
|
20
|
-
<h3><a href="/admin/users">
|
|
25
|
+
<h3><a href="/admin/users">{text('page.admin.dashboard.users_link')}</a></h3>
|
|
21
26
|
|
|
22
|
-
<p>{
|
|
27
|
+
<p>{text('page.admin.dashboard.stats', { users: data.users, sessions: data.sessions, passkeys: data.passkeys })}</p>
|
|
23
28
|
|
|
24
|
-
<h3><a href="/admin/config">
|
|
29
|
+
<h3><a href="/admin/config">{text('page.admin.dashboard.config_link')}</a></h3>
|
|
25
30
|
|
|
26
|
-
<p>{data.configFiles}
|
|
31
|
+
<p>{text('page.admin.dashboard.config_files', { count: data.configFiles })}</p>
|
|
27
32
|
|
|
28
|
-
<h3><a href="/admin/plugins">
|
|
33
|
+
<h3><a href="/admin/plugins">{text('page.admin.dashboard.plugins_link')}</a></h3>
|
|
29
34
|
|
|
30
|
-
<p>{data.plugins}
|
|
35
|
+
<p>{text('page.admin.dashboard.plugins_loaded', { count: data.plugins })}</p>
|
|
31
36
|
|
|
32
|
-
<h3><a href="/admin/audit">
|
|
37
|
+
<h3><a href="/admin/audit">{text('page.admin.audit.heading')}</a></h3>
|
|
33
38
|
|
|
34
39
|
<p>
|
|
35
|
-
{
|
|
36
|
-
.
|
|
37
|
-
.
|
|
40
|
+
{data.auditEvents
|
|
41
|
+
.map((count, severity) => count && `${count} ${Severity[severity].toUpperCase()} events`)
|
|
42
|
+
.filter(v => v)
|
|
38
43
|
.join(', ')}.
|
|
39
44
|
</p>
|
|
40
45
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { text } from '@axium/client';
|
|
2
3
|
import { Icon } from '@axium/client/components';
|
|
3
4
|
import '@axium/client/styles/list';
|
|
4
5
|
import './styles.css';
|
|
@@ -9,14 +10,14 @@
|
|
|
9
10
|
</script>
|
|
10
11
|
|
|
11
12
|
<svelte:head>
|
|
12
|
-
<title>
|
|
13
|
+
<title>{text('page.admin.audit.title')}</title>
|
|
13
14
|
</svelte:head>
|
|
14
15
|
|
|
15
|
-
<h2>
|
|
16
|
+
<h2>{text('page.admin.audit.heading')}</h2>
|
|
16
17
|
|
|
17
18
|
{#if data.filterError}
|
|
18
19
|
<div class="error">
|
|
19
|
-
<strong>
|
|
20
|
+
<strong>{text('page.admin.audit.invalid_filter')}</strong>
|
|
20
21
|
{#each data.filterError.split('\n') as line}
|
|
21
22
|
<p>{line}</p>
|
|
22
23
|
{/each}
|
|
@@ -24,10 +25,10 @@
|
|
|
24
25
|
{/if}
|
|
25
26
|
|
|
26
27
|
<form id="filter" method="dialog">
|
|
27
|
-
<h4>
|
|
28
|
+
<h4>{text('page.admin.audit.filters')}</h4>
|
|
28
29
|
|
|
29
30
|
<div class="filter-field">
|
|
30
|
-
<span>
|
|
31
|
+
<span>{text('page.admin.audit.filter.severity')}</span>
|
|
31
32
|
<select name="severity" value={data.filter.severity}>
|
|
32
33
|
{#each severityNames as value}
|
|
33
34
|
<option {value} selected={value == 'info'}>{capitalize(value)}</option>
|
|
@@ -36,25 +37,25 @@
|
|
|
36
37
|
</div>
|
|
37
38
|
|
|
38
39
|
<div class="filter-field">
|
|
39
|
-
<span>
|
|
40
|
+
<span>{text('page.admin.audit.filter.since')}</span>
|
|
40
41
|
<input type="date" name="since" value={data.filter.since} />
|
|
41
42
|
</div>
|
|
42
43
|
|
|
43
44
|
<div class="filter-field">
|
|
44
|
-
<span>
|
|
45
|
+
<span>{text('page.admin.audit.filter.until')}</span>
|
|
45
46
|
<input type="date" name="until" value={data.filter.until} />
|
|
46
47
|
</div>
|
|
47
48
|
|
|
48
49
|
<div class="filter-field">
|
|
49
|
-
<span>
|
|
50
|
+
<span>{text('page.admin.audit.filter.tags')}</span>
|
|
50
51
|
<input type="text" name="tags" value={data.filter.tags} />
|
|
51
52
|
</div>
|
|
52
53
|
|
|
53
54
|
<div class="filter-field">
|
|
54
|
-
<span>
|
|
55
|
+
<span>{text('page.admin.audit.filter.event')}</span>
|
|
55
56
|
{#if data.configured}
|
|
56
57
|
<select name="event">
|
|
57
|
-
<option value="">
|
|
58
|
+
<option value="">{text('page.admin.audit.any')}</option>
|
|
58
59
|
{#each data.configured.name as name}
|
|
59
60
|
<option value={name} selected={data.filter.event == name}>{name}</option>
|
|
60
61
|
{/each}
|
|
@@ -65,10 +66,10 @@
|
|
|
65
66
|
</div>
|
|
66
67
|
|
|
67
68
|
<div class="filter-field">
|
|
68
|
-
<span>
|
|
69
|
+
<span>{text('page.admin.audit.filter.source')}</span>
|
|
69
70
|
{#if data.configured}
|
|
70
71
|
<select name="source">
|
|
71
|
-
<option value="">
|
|
72
|
+
<option value="">{text('page.admin.audit.any')}</option>
|
|
72
73
|
{#each data.configured.source as source}
|
|
73
74
|
<option value={source} selected={data.filter.source == source}>{source}</option>
|
|
74
75
|
{/each}
|
|
@@ -79,7 +80,7 @@
|
|
|
79
80
|
</div>
|
|
80
81
|
|
|
81
82
|
<div class="filter-field">
|
|
82
|
-
<span>
|
|
83
|
+
<span>{text('page.admin.audit.filter.user')}</span>
|
|
83
84
|
<input type="text" name="user" size="36" value={data.filter.user} />
|
|
84
85
|
</div>
|
|
85
86
|
|
|
@@ -112,14 +113,14 @@
|
|
|
112
113
|
}
|
|
113
114
|
}
|
|
114
115
|
location.search = params ? '?' + params.toString() : '';
|
|
115
|
-
}}>
|
|
116
|
+
}}>{text('page.admin.audit.apply')}</button
|
|
116
117
|
>
|
|
117
118
|
<button
|
|
118
119
|
class="inline-button"
|
|
119
120
|
onclick={e => {
|
|
120
121
|
e.preventDefault();
|
|
121
122
|
location.search = '';
|
|
122
|
-
}}>
|
|
123
|
+
}}>{text('page.admin.audit.reset')}</button
|
|
123
124
|
>
|
|
124
125
|
</div>
|
|
125
126
|
</form>
|
|
@@ -127,17 +128,17 @@
|
|
|
127
128
|
<div class="list-container">
|
|
128
129
|
<div class="list">
|
|
129
130
|
<div class="list-item list-header">
|
|
130
|
-
<span>
|
|
131
|
-
<span>
|
|
132
|
-
<span>
|
|
133
|
-
<span>
|
|
134
|
-
<span>
|
|
135
|
-
<span>
|
|
131
|
+
<span>{text('page.admin.audit.timestamp')}</span>
|
|
132
|
+
<span>{text('page.admin.audit.severity')}</span>
|
|
133
|
+
<span>{text('page.admin.audit.source')}</span>
|
|
134
|
+
<span>{text('page.admin.audit.name')}</span>
|
|
135
|
+
<span>{text('page.admin.audit.tags')}</span>
|
|
136
|
+
<span>{text('page.admin.audit.user')}</span>
|
|
136
137
|
</div>
|
|
137
138
|
|
|
138
139
|
{#each data.events as event}
|
|
139
140
|
<div class="list-item" onclick={e => e.currentTarget === e.target && (location.href = '/admin/audit/' + event.id)}>
|
|
140
|
-
<span>{
|
|
141
|
+
<span>{event.timestamp.toLocaleString()}</span>
|
|
141
142
|
<span class="severity--{Severity[event.severity].toLowerCase()}">{Severity[event.severity]}</span>
|
|
142
143
|
<span>{event.source}</span>
|
|
143
144
|
<span>{event.name}</span>
|
|
@@ -148,12 +149,12 @@
|
|
|
148
149
|
{#if event.userId === data.session?.userId}<span class="subtle">(You)</span>{/if}
|
|
149
150
|
</a>
|
|
150
151
|
{:else}
|
|
151
|
-
<i>
|
|
152
|
+
<i>{text('generic.unknown')}</i>
|
|
152
153
|
{/if}
|
|
153
154
|
<a href="/admin/audit/{event.id}"><Icon i="chevron-right" /></a>
|
|
154
155
|
</div>
|
|
155
156
|
{:else}
|
|
156
|
-
<p class="list-empty">
|
|
157
|
+
<p class="list-empty">{text('page.admin.audit.no_events')}</p>
|
|
157
158
|
{/each}
|
|
158
159
|
</div>
|
|
159
160
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { text } from '@axium/client';
|
|
2
3
|
import { Severity } from '@axium/core/audit';
|
|
3
4
|
import '../styles.css';
|
|
4
5
|
import UserCard from '@axium/client/components/UserCard';
|
|
@@ -8,40 +9,40 @@
|
|
|
8
9
|
</script>
|
|
9
10
|
|
|
10
11
|
<svelte:head>
|
|
11
|
-
<title>
|
|
12
|
+
<title>{text('page.admin.audit.event_title', { id: event.id })}</title>
|
|
12
13
|
</svelte:head>
|
|
13
14
|
|
|
14
|
-
<h2>
|
|
15
|
+
<h2>{text('page.admin.audit.event_heading')}</h2>
|
|
15
16
|
|
|
16
|
-
<h4>
|
|
17
|
+
<h4>{text('page.admin.audit.uuid')}</h4>
|
|
17
18
|
<p>{event.id}</p>
|
|
18
19
|
|
|
19
|
-
<h4>
|
|
20
|
+
<h4>{text('page.admin.audit.severity')}</h4>
|
|
20
21
|
<p class="severity--{Severity[event.severity].toLowerCase()}">{Severity[event.severity]}</p>
|
|
21
22
|
|
|
22
|
-
<h4>
|
|
23
|
+
<h4>{text('page.admin.audit.name')}</h4>
|
|
23
24
|
<p>{event.name}</p>
|
|
24
25
|
|
|
25
|
-
<h4>
|
|
26
|
-
<p>{
|
|
26
|
+
<h4>{text('page.admin.audit.timestamp')}</h4>
|
|
27
|
+
<p>{event.timestamp.toLocaleString()}</p>
|
|
27
28
|
|
|
28
|
-
<h4>
|
|
29
|
+
<h4>{text('page.admin.audit.source')}</h4>
|
|
29
30
|
<p>{event.source}</p>
|
|
30
31
|
|
|
31
|
-
<h4>
|
|
32
|
+
<h4>{text('page.admin.audit.tags')}</h4>
|
|
32
33
|
<p>{event.tags.join(', ')}</p>
|
|
33
34
|
|
|
34
|
-
<h4>
|
|
35
|
+
<h4>{text('page.admin.audit.user')}</h4>
|
|
35
36
|
{#if event.user}
|
|
36
37
|
<UserCard user={event.user} href="/admin/users/{event.user.id}" />
|
|
37
38
|
{:else}
|
|
38
|
-
<i>
|
|
39
|
+
<i>{text('generic.unknown')}</i>
|
|
39
40
|
{/if}
|
|
40
41
|
|
|
41
|
-
<h4>
|
|
42
|
+
<h4>{text('page.admin.audit.extra_data')}</h4>
|
|
42
43
|
|
|
43
44
|
{#if event.name == 'response_error'}
|
|
44
|
-
<h5>
|
|
45
|
+
<h5>{text('page.admin.audit.error_stack')}</h5>
|
|
45
46
|
<pre>{event.extra.stack}</pre>
|
|
46
47
|
{:else}
|
|
47
48
|
<pre>{JSON.stringify(event.extra, null, 4)}</pre>
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { text } from '@axium/client';
|
|
2
3
|
const { data } = $props();
|
|
3
4
|
</script>
|
|
4
5
|
|
|
5
6
|
<svelte:head>
|
|
6
|
-
<title>
|
|
7
|
+
<title>{text('page.admin.config.title')}</title>
|
|
7
8
|
</svelte:head>
|
|
8
9
|
|
|
9
|
-
<h2>
|
|
10
|
+
<h2>{text('page.admin.config.active')}</h2>
|
|
10
11
|
|
|
11
12
|
<pre>{JSON.stringify(data.config, null, 4)}</pre>
|
|
12
13
|
|
|
13
|
-
<h2 id="files">
|
|
14
|
+
<h2 id="files">{text('page.admin.config.loaded_files')}</h2>
|
|
14
15
|
|
|
15
16
|
{#each Object.entries(data.files) as [path, config]}
|
|
16
17
|
<details>
|
|
@@ -1,23 +1,31 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { text } from '@axium/client';
|
|
2
3
|
import { Version, ZodForm } from '@axium/client/components';
|
|
3
4
|
import { fetchAPI } from '@axium/client/requests';
|
|
4
5
|
import { serverConfigs } from '@axium/core';
|
|
6
|
+
import { getPackage } from '@axium/core/packages';
|
|
7
|
+
import { _throw } from 'utilium';
|
|
5
8
|
|
|
6
9
|
const { data } = $props();
|
|
7
10
|
</script>
|
|
8
11
|
|
|
9
12
|
<svelte:head>
|
|
10
|
-
<title>
|
|
13
|
+
<title>{text('page.admin.plugins.title')}</title>
|
|
11
14
|
</svelte:head>
|
|
12
15
|
|
|
13
|
-
<h2>
|
|
16
|
+
<h2>{text('page.admin.plugins.heading')}</h2>
|
|
14
17
|
|
|
15
18
|
{#each data.plugins as plugin}
|
|
16
19
|
{@const cfg = serverConfigs.get(plugin.name)}
|
|
17
20
|
<div class="plugin">
|
|
18
|
-
<h3>
|
|
21
|
+
<h3>
|
|
22
|
+
{plugin.name}<Version
|
|
23
|
+
v={plugin.version}
|
|
24
|
+
latest={plugin.update_checks ? getPackage(plugin.name).then(p => p?._latest || _throw(null)) : null}
|
|
25
|
+
/>
|
|
26
|
+
</h3>
|
|
19
27
|
<p>
|
|
20
|
-
<strong>
|
|
28
|
+
<strong>{text('page.admin.plugins.loaded_from')}</strong>
|
|
21
29
|
{#if plugin.path.endsWith('/package.json')}
|
|
22
30
|
<span class="path plugin-path">{plugin.path.slice(0, -13)}</span>
|
|
23
31
|
{:else}
|
|
@@ -28,18 +36,18 @@
|
|
|
28
36
|
<a class="path" href="/admin/config#{plugin.loadedBy}">{plugin.loadedBy}</a>
|
|
29
37
|
{/if}
|
|
30
38
|
</p>
|
|
31
|
-
<p><strong>
|
|
39
|
+
<p><strong>{text('page.admin.plugins.author')}</strong> {plugin.author}</p>
|
|
32
40
|
<p class="apps">
|
|
33
|
-
<strong>
|
|
41
|
+
<strong>{text('page.admin.plugins.provided_apps')}</strong>
|
|
34
42
|
{#if plugin.apps?.length}
|
|
35
43
|
{#each plugin.apps as app, i}
|
|
36
44
|
<a href="/{app.id}">{app.name}</a>{i != plugin.apps.length - 1 ? ', ' : ''}
|
|
37
45
|
{/each}
|
|
38
|
-
{:else}<i>
|
|
46
|
+
{:else}<i>{text('generic.none')}</i>{/if}
|
|
39
47
|
</p>
|
|
40
48
|
<p>{plugin.description}</p>
|
|
41
49
|
{#if cfg && plugin.config}
|
|
42
|
-
<h4>
|
|
50
|
+
<h4>{text('page.admin.plugins.configuration')}</h4>
|
|
43
51
|
{@const { schema, labels } = cfg}
|
|
44
52
|
<ZodForm
|
|
45
53
|
rootValue={plugin.config}
|
|
@@ -51,7 +59,7 @@
|
|
|
51
59
|
{/if}
|
|
52
60
|
</div>
|
|
53
61
|
{:else}
|
|
54
|
-
<i>
|
|
62
|
+
<i>{text('page.admin.plugins.none')}</i>
|
|
55
63
|
{/each}
|
|
56
64
|
|
|
57
65
|
<style>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { text } from '@axium/client';
|
|
2
3
|
import { FormDialog, Icon, URLText } from '@axium/client/components';
|
|
3
4
|
import { fetchAPI } from '@axium/client/requests';
|
|
4
5
|
import '@axium/client/styles/list';
|
|
@@ -13,10 +14,10 @@
|
|
|
13
14
|
</script>
|
|
14
15
|
|
|
15
16
|
<svelte:head>
|
|
16
|
-
<title>
|
|
17
|
+
<title>{text('page.admin.users.title')}</title>
|
|
17
18
|
</svelte:head>
|
|
18
19
|
|
|
19
|
-
<h2>
|
|
20
|
+
<h2>{text('page.admin.users.heading')}</h2>
|
|
20
21
|
|
|
21
22
|
{#snippet attr(i: string, text: string, color: string = colorHashRGB(text))}
|
|
22
23
|
<span class="attribute" style:background-color={color}><Icon {i} />{text}</span>
|
|
@@ -24,12 +25,12 @@
|
|
|
24
25
|
|
|
25
26
|
<button command="show-modal" commandfor="create-user" class="icon-text">
|
|
26
27
|
<Icon i="plus" />
|
|
27
|
-
|
|
28
|
+
{text('page.admin.users.create')}
|
|
28
29
|
</button>
|
|
29
30
|
|
|
30
31
|
<FormDialog
|
|
31
32
|
id="create-user"
|
|
32
|
-
submitText=
|
|
33
|
+
submitText={text('generic.create')}
|
|
33
34
|
submit={(data: { email: string; name: string }) =>
|
|
34
35
|
fetchAPI('PUT', 'admin/users', data).then(res => {
|
|
35
36
|
verification = res.verification;
|
|
@@ -38,30 +39,30 @@
|
|
|
38
39
|
})}
|
|
39
40
|
>
|
|
40
41
|
<div>
|
|
41
|
-
<label for="email">
|
|
42
|
+
<label for="email">{text('generic.email')}</label>
|
|
42
43
|
<input name="email" type="email" required />
|
|
43
44
|
</div>
|
|
44
45
|
<div>
|
|
45
|
-
<label for="name">
|
|
46
|
+
<label for="name">{text('generic.username')}</label>
|
|
46
47
|
<input name="name" type="text" required />
|
|
47
48
|
</div>
|
|
48
49
|
</FormDialog>
|
|
49
50
|
|
|
50
51
|
<dialog bind:this={createdUserDialog} id="created-user-verification">
|
|
51
|
-
<h3>
|
|
52
|
+
<h3>{text('page.admin.users.created_title')}</h3>
|
|
52
53
|
|
|
53
|
-
<p>
|
|
54
|
+
<p>{text('page.admin.users.created_url')}</p>
|
|
54
55
|
|
|
55
56
|
<URLText url="/login/token?user={verification?.userId}&token={verification?.token}" />
|
|
56
57
|
|
|
57
|
-
<button onclick={() => createdUserDialog?.close()}>
|
|
58
|
+
<button onclick={() => createdUserDialog?.close()}>{text('generic.ok')}</button>
|
|
58
59
|
</dialog>
|
|
59
60
|
|
|
60
61
|
<div id="user-list" class="list">
|
|
61
62
|
<div class="list-item list-header">
|
|
62
|
-
<span>
|
|
63
|
-
<span>
|
|
64
|
-
<span>
|
|
63
|
+
<span>{text('generic.username')}</span>
|
|
64
|
+
<span>{text('generic.email')}</span>
|
|
65
|
+
<span>{text('page.admin.users.attributes')}</span>
|
|
65
66
|
</div>
|
|
66
67
|
{#each users as user}
|
|
67
68
|
<div class="user list-item" onclick={e => e.currentTarget === e.target && (location.href = '/admin/users/' + user.id)}>
|
|
@@ -69,7 +70,7 @@
|
|
|
69
70
|
<span>{user.email}</span>
|
|
70
71
|
<span class="mobile-hide">
|
|
71
72
|
{#if user.isAdmin}
|
|
72
|
-
{@render attr('crown', '
|
|
73
|
+
{@render attr('crown', text('page.admin.users.admin_tag'), '#710')}
|
|
73
74
|
{/if}
|
|
74
75
|
{#each user.tags as tag}
|
|
75
76
|
{@render attr('hashtag', tag)}
|
|
@@ -80,15 +81,15 @@
|
|
|
80
81
|
</span>
|
|
81
82
|
<a class="icon-text mobile-button" href="/admin/audit?user={user.id}">
|
|
82
83
|
<Icon i="file-shield" />
|
|
83
|
-
<span class="mobile-only">
|
|
84
|
+
<span class="mobile-only">{text('page.admin.users.audit')}</span>
|
|
84
85
|
</a>
|
|
85
86
|
<a class="icon-text mobile-button" href="/admin/users/{user.id}">
|
|
86
87
|
<Icon i="chevron-right" />
|
|
87
|
-
<span class="mobile-only">
|
|
88
|
+
<span class="mobile-only">{text('page.admin.users.manage')}</span>
|
|
88
89
|
</a>
|
|
89
90
|
</div>
|
|
90
91
|
{:else}
|
|
91
|
-
<div class="error">
|
|
92
|
+
<div class="error">{text('page.admin.users.none')}</div>
|
|
92
93
|
{/each}
|
|
93
94
|
</div>
|
|
94
95
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { text } from '@axium/client';
|
|
2
3
|
import { ClipboardCopy, FormDialog, Icon, SessionList, ZodForm, ZodInput } from '@axium/client/components';
|
|
3
4
|
import { fetchAPI } from '@axium/client/requests';
|
|
4
5
|
import '@axium/client/styles/account';
|
|
@@ -19,108 +20,110 @@
|
|
|
19
20
|
</script>
|
|
20
21
|
|
|
21
22
|
<svelte:head>
|
|
22
|
-
<title>
|
|
23
|
+
<title>{text('page.admin.users.manage_title')}</title>
|
|
23
24
|
</svelte:head>
|
|
24
25
|
|
|
25
26
|
<a href="/admin/users">
|
|
26
27
|
<button class="icon-text">
|
|
27
|
-
<Icon i="up-left" />
|
|
28
|
+
<Icon i="up-left" />
|
|
29
|
+
{text('page.admin.users.back')}
|
|
28
30
|
</button>
|
|
29
31
|
</a>
|
|
30
32
|
|
|
31
|
-
<h2>
|
|
33
|
+
<h2>{text('page.admin.users.manage_heading')}</h2>
|
|
32
34
|
|
|
33
35
|
<div id="info" class="section main">
|
|
34
36
|
<div class="item info">
|
|
35
|
-
<p>
|
|
37
|
+
<p>{text('page.admin.users.uuid')}</p>
|
|
36
38
|
<p>{user.id}</p>
|
|
37
39
|
<ClipboardCopy value={user.id} --size="16px" />
|
|
38
40
|
</div>
|
|
39
41
|
|
|
40
42
|
<div class="item info">
|
|
41
|
-
<p>
|
|
43
|
+
<p>{text('page.admin.users.display_name')}</p>
|
|
42
44
|
<p>{user.name}</p>
|
|
43
45
|
<ClipboardCopy value={user.name} --size="16px" />
|
|
44
46
|
</div>
|
|
45
47
|
|
|
46
48
|
<div class="item info">
|
|
47
|
-
<p>
|
|
49
|
+
<p>{text('generic.email')}</p>
|
|
48
50
|
<p>
|
|
49
51
|
<a href="mailto:{user.email}">{user.email}</a>, {user.emailVerified
|
|
50
|
-
? '
|
|
51
|
-
: '
|
|
52
|
+
? text('page.admin.users.email_verified', { date: user.emailVerified.toLocaleString() })
|
|
53
|
+
: text('page.admin.users.email_not_verified')}
|
|
52
54
|
</p>
|
|
53
55
|
<ClipboardCopy value={user.email} --size="16px" />
|
|
54
56
|
</div>
|
|
55
57
|
|
|
56
58
|
<div class="item info">
|
|
57
|
-
<p>
|
|
59
|
+
<p>{text('page.admin.users.registered')}</p>
|
|
58
60
|
<p>{formatDateRange(user.registeredAt)}</p>
|
|
59
61
|
<ClipboardCopy value={user.registeredAt.toISOString()} --size="16px" />
|
|
60
62
|
</div>
|
|
61
63
|
<div class="item info">
|
|
62
|
-
<p>
|
|
64
|
+
<p>{text('page.admin.users.administrator')}</p>
|
|
63
65
|
{#if user.isAdmin}
|
|
64
|
-
<strong>
|
|
66
|
+
<strong>{text('generic.yes')}</strong>
|
|
65
67
|
{:else}
|
|
66
|
-
<p>
|
|
68
|
+
<p>{text('generic.no')}</p>
|
|
67
69
|
{/if}
|
|
68
70
|
<p></p>
|
|
69
71
|
</div>
|
|
70
72
|
<div class="item info">
|
|
71
|
-
<p>
|
|
73
|
+
<p>{text('page.admin.users.suspended')}</p>
|
|
72
74
|
{#if user.isSuspended}
|
|
73
|
-
<strong>
|
|
75
|
+
<strong>{text('generic.yes')}</strong>
|
|
74
76
|
{:else}
|
|
75
|
-
<p>
|
|
77
|
+
<p>{text('generic.no')}</p>
|
|
76
78
|
{/if}
|
|
77
79
|
<button
|
|
78
80
|
onclick={async () => {
|
|
79
81
|
const { isSuspended } = await fetchAPI('PATCH', 'admin/users', { isSuspended: !user.isSuspended, id: user.id });
|
|
80
82
|
user.isSuspended = isSuspended;
|
|
81
|
-
}}>{user.isSuspended ? '
|
|
83
|
+
}}>{user.isSuspended ? text('page.admin.users.unsuspend') : text('page.admin.users.suspend')}</button
|
|
82
84
|
>
|
|
83
85
|
</div>
|
|
84
86
|
<div class="item info">
|
|
85
|
-
<p>
|
|
87
|
+
<p>{text('page.admin.users.profile_image')}</p>
|
|
86
88
|
{#if user.image}
|
|
87
89
|
<a href={user.image} target="_blank" rel="noopener noreferrer">{user.image}</a>
|
|
88
90
|
<ClipboardCopy value={user.image} --size="16px" />
|
|
89
91
|
{:else}
|
|
90
|
-
<i>
|
|
92
|
+
<i>{text('page.admin.users.default_image')}</i>
|
|
91
93
|
<p></p>
|
|
92
94
|
{/if}
|
|
93
95
|
</div>
|
|
94
96
|
<div class="item info">
|
|
95
|
-
<p>
|
|
97
|
+
<p>{text('page.admin.users.roles')}</p>
|
|
96
98
|
<ZodInput bind:rootValue={user} path="roles" schema={User.shape.roles} {updateValue} noLabel />
|
|
97
99
|
</div>
|
|
98
100
|
<div class="item info">
|
|
99
|
-
<p>
|
|
101
|
+
<p>{text('page.admin.users.tags')}</p>
|
|
100
102
|
<ZodInput bind:rootValue={user} path="tags" schema={User.shape.tags} {updateValue} noLabel />
|
|
101
103
|
</div>
|
|
102
104
|
|
|
103
105
|
<button class="inline-button icon-text danger" command="show-modal" commandfor="delete-user">
|
|
104
|
-
<Icon i="trash" />
|
|
106
|
+
<Icon i="trash" />
|
|
107
|
+
{text('page.admin.users.delete_user')}
|
|
105
108
|
</button>
|
|
106
109
|
|
|
107
110
|
<FormDialog
|
|
108
111
|
id="delete-user"
|
|
109
112
|
submit={() => deleteUser(user.id, session?.userId).then(() => (window.location.href = '/admin/users'))}
|
|
110
|
-
submitText=
|
|
113
|
+
submitText={text('page.admin.users.delete_user')}
|
|
111
114
|
submitDanger
|
|
112
115
|
>
|
|
113
|
-
<p>
|
|
116
|
+
<p>{text('page.admin.users.delete_confirm')}<br />{text('generic.action_irreversible')}</p>
|
|
114
117
|
</FormDialog>
|
|
115
118
|
</div>
|
|
116
119
|
|
|
117
120
|
<div id="sessions" class="section main">
|
|
118
|
-
<h3>
|
|
121
|
+
<h3>{text('generic.sessions')}</h3>
|
|
119
122
|
<SessionList {sessions} {user} />
|
|
120
123
|
</div>
|
|
121
124
|
|
|
122
125
|
<div id="preferences" class="section main">
|
|
123
|
-
<h3>
|
|
126
|
+
<h3>{text('generic.preferences')}</h3>
|
|
124
127
|
<ZodForm
|
|
125
128
|
bind:rootValue={user.preferences}
|
|
126
129
|
schema={Preferences}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { text } from '@axium/client';
|
|
2
3
|
import Icon from '@axium/client/components/Icon';
|
|
3
4
|
import { fetchAPI } from '@axium/client/requests';
|
|
4
5
|
import { startAuthentication } from '@simplewebauthn/browser';
|
|
@@ -28,7 +29,7 @@
|
|
|
28
29
|
</script>
|
|
29
30
|
|
|
30
31
|
<svelte:head>
|
|
31
|
-
<title>
|
|
32
|
+
<title>{text('page.login.client.title')}</title>
|
|
32
33
|
</svelte:head>
|
|
33
34
|
|
|
34
35
|
{#if error}
|
|
@@ -36,15 +37,15 @@
|
|
|
36
37
|
{:else if authDone}
|
|
37
38
|
<div class="center success">
|
|
38
39
|
<h1><Icon i="check" /></h1>
|
|
39
|
-
<p>
|
|
40
|
+
<p>{text('page.login.client.success')}</p>
|
|
40
41
|
</div>
|
|
41
42
|
{:else}
|
|
42
43
|
<div id="local-login" class="center">
|
|
43
|
-
<h2>
|
|
44
|
-
<p>
|
|
44
|
+
<h2>{text('page.login.client.title')}</h2>
|
|
45
|
+
<p>{text('page.login.client.confirm')}</p>
|
|
45
46
|
<div>
|
|
46
|
-
<button>
|
|
47
|
-
<button class="danger" {onclick}>
|
|
47
|
+
<button>{text('generic.cancel')}</button>
|
|
48
|
+
<button class="danger" {onclick}>{text('page.login.client.authorize')}</button>
|
|
48
49
|
</div>
|
|
49
50
|
</div>
|
|
50
51
|
{/if}
|
|
@@ -67,6 +68,6 @@
|
|
|
67
68
|
|
|
68
69
|
#local-login {
|
|
69
70
|
background-color: var(--bg-menu);
|
|
70
|
-
border:
|
|
71
|
+
border: var(--border-accent);
|
|
71
72
|
}
|
|
72
73
|
</style>
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { text } from '@axium/client';
|
|
2
3
|
import Register from '@axium/client/components/Register';
|
|
3
4
|
</script>
|
|
4
5
|
|
|
5
6
|
<svelte:head>
|
|
6
|
-
<title>
|
|
7
|
+
<title>{text('generic.register')}</title>
|
|
7
8
|
</svelte:head>
|
|
8
9
|
|
|
9
10
|
<Register fullPage />
|