@axium/client 0.33.5 → 0.35.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/assets/styles.css CHANGED
@@ -441,6 +441,42 @@ div.toast {
441
441
  border: var(--border-accent);
442
442
  background-color: var(--bg-accent);
443
443
  }
444
+
445
+ &.progress {
446
+ border: var(--border-accent);
447
+ background-color: var(--bg-accent);
448
+ display: flex;
449
+ flex-direction: column;
450
+ gap: 0.25em;
451
+ text-align: left;
452
+ min-width: 16em;
453
+
454
+ .toast-header {
455
+ display: flex;
456
+ align-items: center;
457
+ justify-content: space-between;
458
+ gap: 1em;
459
+
460
+ button {
461
+ display: inline-flex;
462
+ cursor: pointer;
463
+ }
464
+ }
465
+
466
+ .subtle {
467
+ opacity: 69%;
468
+ font-size: 0.85em;
469
+
470
+ &:empty {
471
+ display: none;
472
+ }
473
+ }
474
+
475
+ progress {
476
+ width: 100%;
477
+ height: 4px;
478
+ }
479
+ }
444
480
  }
445
481
 
446
482
  @media (width >= 700px) {
@@ -34,8 +34,8 @@ export declare const meta: Handle<z.ZodObject<{
34
34
  user: z.ZodObject<{
35
35
  id: z.ZodUUID;
36
36
  name: z.ZodString;
37
- username: z.ZodOptional<z.ZodNullable<z.ZodString>>;
38
- email: z.ZodEmail;
37
+ username: z.ZodString;
38
+ email: z.ZodOptional<z.ZodNullable<z.ZodEmail>>;
39
39
  emailVerified: z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>;
40
40
  preferences: z.ZodLazy<z.ZodObject<{
41
41
  debug: z.ZodDefault<z.ZodBoolean>;
@@ -10,7 +10,7 @@ export declare function session(): {
10
10
  user: {
11
11
  id: string;
12
12
  name: string;
13
- email: string;
13
+ username: string;
14
14
  preferences: {
15
15
  debug: boolean;
16
16
  };
@@ -19,7 +19,7 @@ export declare function session(): {
19
19
  registeredAt: Date;
20
20
  isAdmin: boolean;
21
21
  isSuspended: boolean;
22
- username?: string | null | undefined;
22
+ email?: string | null | undefined;
23
23
  emailVerified?: Date | null | undefined;
24
24
  };
25
25
  name?: string | null | undefined;
package/dist/locales.d.ts CHANGED
@@ -29,7 +29,6 @@ declare let currentLoaded: {
29
29
  readonly no_results: "No results";
30
30
  };
31
31
  readonly Login: {
32
- readonly email: "Email";
33
32
  readonly register: "Register instead";
34
33
  };
35
34
  readonly Logout: {
@@ -37,9 +36,7 @@ declare let currentLoaded: {
37
36
  readonly question: "Are you sure you want to log out?";
38
37
  };
39
38
  readonly Register: {
40
- readonly email: "Email";
41
39
  readonly login: "Login instead";
42
- readonly name: "Display Name";
43
40
  };
44
41
  readonly SessionList: {
45
42
  readonly created: "Created {date}";
@@ -84,6 +81,8 @@ declare let currentLoaded: {
84
81
  readonly none: "None";
85
82
  readonly ok: "Okay";
86
83
  readonly preferences: "Preferences";
84
+ readonly recovery: "Recovery";
85
+ readonly recovery_method_disabled: "This recovery method is disabled and can't be used.";
87
86
  readonly register: "Register";
88
87
  readonly rename: "Rename";
89
88
  readonly sessions: "Sessions";
@@ -91,7 +90,7 @@ declare let currentLoaded: {
91
90
  readonly success: "Success";
92
91
  readonly unknown: "Unknown";
93
92
  readonly unnamed: "Unnamed";
94
- readonly user_display_name: "Name";
93
+ readonly user_display_name: "Display name";
95
94
  readonly username: "Username";
96
95
  readonly yes: "Yes";
97
96
  };
@@ -127,8 +126,11 @@ declare let currentLoaded: {
127
126
  readonly toast_updated: "Profile picture updated";
128
127
  };
129
128
  readonly preferences: "Preferences";
129
+ readonly remove_email: "Remove email";
130
+ readonly remove_email_confirm: "Are you sure you want to remove your recovery email?";
130
131
  readonly sessions: "Sessions";
131
132
  readonly title: "Your Account";
133
+ readonly toast_username_updated: "Username updated";
132
134
  readonly user_id: "User ID";
133
135
  readonly user_id_hint: "This is your UUID. It can't be changed.";
134
136
  readonly verification_sent: "Verification email sent";
package/dist/user.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { NewSessionResponse, Passkey, PasskeyChangeable, Session, User, UserChangeable, UserPublic, Verification } from '@axium/core';
1
+ import type { AuthInfo, NewSessionResponse, Passkey, PasskeyChangeable, Session, User, UserChangeable, UserPublic, Verification } from '@axium/core';
2
2
  import * as z from 'zod';
3
3
  import Cache from './cache.js';
4
4
  export declare function login(userId: string): Promise<NewSessionResponse>;
@@ -6,7 +6,7 @@ export declare function login(userId: string): Promise<NewSessionResponse>;
6
6
  * Create an elevated session for the user to perform sensitive actions.
7
7
  */
8
8
  export declare function elevate(userId: string): Promise<void>;
9
- export declare function loginByEmail(email: string): Promise<NewSessionResponse>;
9
+ export declare function loginByUsername(username: string): Promise<NewSessionResponse>;
10
10
  export declare function getCurrentSession(): Promise<Session & {
11
11
  user: User;
12
12
  }>;
@@ -27,7 +27,10 @@ export declare function fullUserInfo(userId: string): Promise<User & {
27
27
  * @param deletingId The UUID of the user performing the deletion (for authentication). Defaults to userId.
28
28
  */
29
29
  export declare function deleteUser(userId: string, deletingId?: string): Promise<User>;
30
- export declare function emailVerificationEnabled(userId: string): Promise<boolean>;
30
+ /**
31
+ * Get which authentication-related features are available, e.g. account recovery methods.
32
+ */
33
+ export declare function getAuthInfo(userId: string): Promise<AuthInfo>;
31
34
  export declare function sendVerificationEmail(userId: string): Promise<Verification>;
32
35
  export declare function verifyEmail(userId: string, token: string): Promise<void>;
33
36
  export declare function getPasskeys(userId: string): Promise<Passkey[]>;
package/dist/user.js CHANGED
@@ -1,7 +1,8 @@
1
+ import { UserRegistrationInit } from '@axium/core/user';
1
2
  import { startAuthentication, startRegistration } from '@simplewebauthn/browser';
2
3
  import * as z from 'zod';
3
- import { fetchAPI } from './requests.js';
4
4
  import Cache from './cache.js';
5
+ import { fetchAPI } from './requests.js';
5
6
  export async function login(userId) {
6
7
  const options = await fetchAPI('PUT', 'users/:id/auth', { type: 'login' }, userId);
7
8
  const response = await startAuthentication({ optionsJSON: options });
@@ -15,11 +16,8 @@ export async function elevate(userId) {
15
16
  const response = await startAuthentication({ optionsJSON: options });
16
17
  await fetchAPI('POST', 'users/:id/auth', response, userId);
17
18
  }
18
- export async function loginByEmail(email) {
19
- const { id: userId } = await fetchAPI('POST', 'user_id', {
20
- using: 'email',
21
- value: email,
22
- });
19
+ export async function loginByUsername(username) {
20
+ const { id: userId } = await fetchAPI('POST', 'user_id', { using: 'username', value: username });
23
21
  return await login(userId);
24
22
  }
25
23
  let _currentSession = null;
@@ -44,15 +42,10 @@ export async function logoutCurrentSession() {
44
42
  return await fetchAPI('DELETE', 'session');
45
43
  }
46
44
  export async function register(_data) {
47
- const data = z.object({ name: z.string(), email: z.email() }).parse(_data);
45
+ const data = UserRegistrationInit.parse(_data);
48
46
  const { options, userId } = await fetchAPI('PUT', 'register', data);
49
47
  const response = await startRegistration({ optionsJSON: options });
50
- await fetchAPI('POST', 'register', {
51
- userId,
52
- name: data.name,
53
- email: data.email,
54
- response,
55
- });
48
+ await fetchAPI('POST', 'register', { userId, response, ...data });
56
49
  }
57
50
  function _checkId(userId) {
58
51
  try {
@@ -91,10 +84,12 @@ export async function deleteUser(userId, deletingId = userId) {
91
84
  userCache.invalidate(userId);
92
85
  return result;
93
86
  }
94
- export async function emailVerificationEnabled(userId) {
87
+ /**
88
+ * Get which authentication-related features are available, e.g. account recovery methods.
89
+ */
90
+ export async function getAuthInfo(userId) {
95
91
  _checkId(userId);
96
- const { enabled } = await fetchAPI('OPTIONS', 'users/:id/verify/email', {}, userId);
97
- return enabled;
92
+ return await fetchAPI('OPTIONS', 'users/:id/auth', {}, userId);
98
93
  }
99
94
  export async function sendVerificationEmail(userId) {
100
95
  _checkId(userId);
@@ -39,7 +39,7 @@
39
39
  </div>
40
40
 
41
41
  {#each acl as control, i (control.userId || control.role || control.tag)}
42
- {@const update = (key: string) => async (e: Event & { currentTarget: HTMLInputElement }) => {
42
+ {const update = (key: string) => async (e: Event & { currentTarget: HTMLInputElement }) => {
43
43
  try {
44
44
  const updated = await updateACL(itemType, item.id, getTarget(control), { [key]: e.currentTarget.checked });
45
45
  Object.assign(control, updated);
@@ -80,7 +80,7 @@
80
80
  </div>
81
81
  <div class="permissions">
82
82
  {#each Object.entries(pickPermissions(control) as Record<string, boolean>) as [key, value]}
83
- {@const id = `${item.id}.${getTarget(control)}.${key}`}
83
+ {const id = $derived(`${item.id}.${getTarget(control)}.${key}`)}
84
84
  <span class="icon-text">
85
85
  {#if editable}
86
86
  <input {id} type="checkbox" onchange={update(key)} />
@@ -24,7 +24,7 @@
24
24
  /** Change the text displayed for the submit button */
25
25
  submitText?: string;
26
26
  /** Called on submission, this should do the actual submission */
27
- submit?(data: Record<string, FormDataEntryValue>): Promise<any>;
27
+ submit?(data: Record<string, FormDataEntryValue>): any;
28
28
  /** Whether to display the dialog as a full-page form */
29
29
  pageMode?: boolean;
30
30
  submitDanger?: boolean;
@@ -47,7 +47,7 @@
47
47
  function onsubmit(e: SubmitEvent & { currentTarget: HTMLFormElement }) {
48
48
  e.preventDefault();
49
49
  const data = Object.fromEntries(new FormData(e.currentTarget));
50
- submit(data)
50
+ Promise.resolve(submit(data))
51
51
  .then(result => {
52
52
  if (!pageMode) dialog!.close();
53
53
  })
@@ -0,0 +1,117 @@
1
+ <script lang="ts">
2
+ import { text } from '@axium/client';
3
+ import { prettifyError, type ZodType } from 'zod';
4
+ import Icon from './Icon.svelte';
5
+
6
+ let {
7
+ value,
8
+ commit,
9
+ close,
10
+ schema,
11
+ optional = false,
12
+ selectionRange,
13
+ placeholder,
14
+ confirmLabel = text('generic.change'),
15
+ }: {
16
+ /** The committed value being edited. */
17
+ value?: string | null;
18
+ /** Called with the new value when a change is confirmed. `null` means the value was cleared (only possible when `optional`). */
19
+ commit(value: string): unknown;
20
+ /** Called when editing ends, whether or not a change was committed. */
21
+ close(): void;
22
+ /** Validates the draft as the user types and before committing. Errors float above the input. */
23
+ schema?: ZodType;
24
+ /** Whether clearing the input commits `null` instead of closing without changes. */
25
+ optional?: boolean;
26
+ /** The range of the value to select when editing starts. Defaults to the entire value. */
27
+ selectionRange?: [number, number];
28
+ placeholder?: string;
29
+ confirmLabel?: string;
30
+ } = $props();
31
+
32
+ let draft = $state(value ?? ''),
33
+ error = $state<string>();
34
+
35
+ function validate(val: string | null): boolean {
36
+ const result = val === null && optional ? undefined : schema?.safeParse(val);
37
+ error = result?.error ? prettifyError(result.error) : undefined;
38
+ return !result || result.success;
39
+ }
40
+
41
+ function confirm() {
42
+ const newValue = draft.trim() || null;
43
+ if (newValue === (value ?? null) || (newValue === null && !optional)) return close();
44
+ if (!validate(newValue)) return;
45
+ commit(newValue!);
46
+ close();
47
+ }
48
+ </script>
49
+
50
+ <span
51
+ class="InlineEdit"
52
+ data-no-select
53
+ onclick={e => e.stopPropagation()}
54
+ {@attach span => {
55
+ const input = span.querySelector('input')!;
56
+ requestAnimationFrame(() => {
57
+ input.focus();
58
+ input.setSelectionRange(...(selectionRange ?? [0, draft.length]));
59
+ });
60
+ const onOutside = (e: PointerEvent) => !span.contains(e.target as Node) && close();
61
+ requestAnimationFrame(() => document.addEventListener('pointerdown', onOutside, true));
62
+ return () => document.removeEventListener('pointerdown', onOutside, true);
63
+ }}
64
+ >
65
+ {#if error}<span class="InlineEdit-error">{error}</span>{/if}
66
+ <input
67
+ bind:value={draft}
68
+ {placeholder}
69
+ class={[error && 'error']}
70
+ oninput={() => validate(draft.trim() || null)}
71
+ onkeydown={e => {
72
+ if (e.key == 'Enter') confirm();
73
+ else if (e.key == 'Escape') close();
74
+ }}
75
+ />
76
+ <button class="reset InlineEdit-confirm" aria-label={confirmLabel} onclick={confirm}>
77
+ <Icon i="check" />
78
+ </button>
79
+ </span>
80
+
81
+ <style>
82
+ .InlineEdit {
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 0.5em;
86
+ min-width: 0;
87
+ anchor-scope: --inline-edit;
88
+ }
89
+
90
+ input {
91
+ background: var(--bg-normal);
92
+ border: var(--border-accent);
93
+ border-radius: 0.25em;
94
+ padding: 0.1em 0.25em;
95
+ margin: -0.1em 0;
96
+ font: inherit;
97
+ color: inherit;
98
+ min-width: 0;
99
+ flex: 1 1 auto;
100
+ anchor-name: --inline-edit;
101
+ }
102
+
103
+ .InlineEdit-error {
104
+ position: fixed;
105
+ position-anchor: --inline-edit;
106
+ bottom: calc(anchor(top) - 0.3em);
107
+ left: anchor(left);
108
+ color: var(--fg-error);
109
+ }
110
+
111
+ .InlineEdit-confirm {
112
+ display: inline-flex;
113
+ align-items: center;
114
+ flex: 0 0 auto;
115
+ cursor: pointer;
116
+ }
117
+ </style>
package/lib/Login.svelte CHANGED
@@ -1,16 +1,16 @@
1
1
  <script lang="ts">
2
- import { loginByEmail, text } from '@axium/client';
2
+ import { loginByUsername, text } from '@axium/client';
3
3
  import FormDialog from './FormDialog.svelte';
4
4
  import authRedirect from './auth_redirect.js';
5
5
 
6
6
  let { dialog = $bindable(), fullPage = false }: { dialog?: HTMLDialogElement; fullPage?: boolean } = $props();
7
7
 
8
- async function submit(data: { email: string }) {
9
- if (typeof data.email != 'string') {
10
- throw 'Tried to upload a file for an email. Huh?!';
8
+ async function submit(data: { username: string }) {
9
+ if (typeof data.username != 'string') {
10
+ throw 'Tried to upload a file for a username. Huh?!';
11
11
  }
12
12
 
13
- await loginByEmail(data.email);
13
+ await loginByUsername(data.username);
14
14
  const redirectAfter = await authRedirect();
15
15
  if (fullPage && redirectAfter) location.href = redirectAfter;
16
16
  }
@@ -18,8 +18,8 @@
18
18
 
19
19
  <FormDialog bind:dialog submitText={text('generic.login')} {submit} pageMode={fullPage}>
20
20
  <div>
21
- <label for="email">{text('Login.email')}</label>
22
- <input name="email" type="email" required />
21
+ <label for="username">{text('generic.username')}</label>
22
+ <input name="username" type="text" required />
23
23
  </div>
24
24
  {#snippet footer()}
25
25
  {#if fullPage}
@@ -14,12 +14,12 @@
14
14
 
15
15
  <FormDialog bind:dialog submitText={text('generic.register')} {submit} pageMode={fullPage}>
16
16
  <div>
17
- <label for="name">{text('Register.name')}</label>
17
+ <label for="name">{text('generic.user_display_name')}</label>
18
18
  <input name="name" type="text" required />
19
19
  </div>
20
20
  <div>
21
- <label for="email">{text('Register.email')}</label>
22
- <input name="email" type="email" required />
21
+ <label for="username">{text('generic.username')}</label>
22
+ <input name="username" type="text" required />
23
23
  </div>
24
24
  {#snippet footer()}
25
25
  {#if fullPage}
package/lib/Upload.svelte CHANGED
@@ -11,7 +11,8 @@
11
11
  ...rest
12
12
  }: HTMLInputAttributes & { input?: HTMLInputElement; progress?: [current: number, max: number][] } = $props();
13
13
 
14
- const id = $props.id();
14
+ const defaultId = $props.id();
15
+ const id = rest.id || defaultId;
15
16
  </script>
16
17
 
17
18
  <div>
package/lib/index.ts CHANGED
@@ -9,6 +9,7 @@ export { default as Discovery } from './Discovery.svelte';
9
9
  export * as discovery from './Discovery.svelte';
10
10
  export { default as FormDialog } from './FormDialog.svelte';
11
11
  export { default as Icon } from './Icon.svelte';
12
+ export { default as InlineEdit } from './InlineEdit.svelte';
12
13
  export { default as LocationSelect } from './LocationSelect.svelte';
13
14
  export { default as Login } from './Login.svelte';
14
15
  export { default as Logout } from './Logout.svelte';
package/lib/toast.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { debug, errorText } from 'ioium';
1
+ import { debug, errorText, useProgress } from 'ioium';
2
2
  import { text } from '@axium/client/locales';
3
3
  import { animate, onAnimationEnd } from '@axium/client/gui';
4
4
  import Icon from './Icon.svelte';
@@ -83,4 +83,74 @@ export async function toastStatus(promise: Promise<unknown>, successMessage: str
83
83
  }
84
84
  }
85
85
 
86
+ let progressToast: HTMLDivElement | undefined,
87
+ progressMain: HTMLSpanElement,
88
+ progressMessage: HTMLSpanElement,
89
+ progressBar: HTMLProgressElement,
90
+ progressCancelButton: HTMLButtonElement,
91
+ progressCancel: (() => void) | undefined;
92
+
93
+ function warnBeforeUnload(e: BeforeUnloadEvent) {
94
+ e.preventDefault();
95
+ }
96
+
97
+ /**
98
+ * Register a callback used to cancel the operation shown in the progress toast.
99
+ * The toast's cancel button is only shown while a callback is registered.
100
+ */
101
+ export function setProgressCancel(cancel?: () => void): void {
102
+ progressCancel = cancel;
103
+ if (progressToast) progressCancelButton.style.display = cancel ? '' : 'none';
104
+ }
105
+
106
+ useProgress({
107
+ start(message: string): void {
108
+ if (!progressToast) {
109
+ progressToast = document.createElement('div');
110
+ progressToast.classList.add('toast', 'progress');
111
+
112
+ const header = document.createElement('div');
113
+ header.classList.add('toast-header');
114
+ progressToast.appendChild(header);
115
+
116
+ progressMain = document.createElement('span');
117
+ header.appendChild(progressMain);
118
+
119
+ progressCancelButton = document.createElement('button');
120
+ progressCancelButton.classList.add('reset');
121
+ progressCancelButton.onclick = () => progressCancel?.();
122
+ mount(Icon, { target: progressCancelButton, props: { i: 'xmark-large' } });
123
+ header.appendChild(progressCancelButton);
124
+
125
+ progressMessage = document.createElement('span');
126
+ progressMessage.classList.add('subtle');
127
+ progressToast.appendChild(progressMessage);
128
+
129
+ progressBar = document.createElement('progress');
130
+ progressToast.appendChild(progressBar);
131
+
132
+ list.appendChild(progressToast);
133
+ addEventListener('beforeunload', warnBeforeUnload);
134
+ }
135
+ progressMain.textContent = message;
136
+ progressMessage.textContent = '';
137
+ progressCancelButton.style.display = progressCancel ? '' : 'none';
138
+ progressBar.removeAttribute('value');
139
+ progressBar.max = 1;
140
+ },
141
+ progress(value: number, max: number, message?: any): void {
142
+ if (!progressToast) return;
143
+ progressBar.value = value;
144
+ progressBar.max = max;
145
+ progressMessage.textContent = message == undefined ? '' : String(message);
146
+ },
147
+ done(): void {
148
+ if (!progressToast) return;
149
+ progressToast.remove();
150
+ progressToast = undefined;
151
+ progressCancel = undefined;
152
+ removeEventListener('beforeunload', warnBeforeUnload);
153
+ },
154
+ });
155
+
86
156
  Object.assign(globalThis, { toast, toastStatus });
package/locales/en.json CHANGED
@@ -23,7 +23,6 @@
23
23
  "no_results": "No results"
24
24
  },
25
25
  "Login": {
26
- "email": "Email",
27
26
  "register": "Register instead"
28
27
  },
29
28
  "Logout": {
@@ -31,9 +30,7 @@
31
30
  "question": "Are you sure you want to log out?"
32
31
  },
33
32
  "Register": {
34
- "email": "Email",
35
- "login": "Login instead",
36
- "name": "Display Name"
33
+ "login": "Login instead"
37
34
  },
38
35
  "SessionList": {
39
36
  "created": "Created {date}",
@@ -78,6 +75,8 @@
78
75
  "none": "None",
79
76
  "ok": "Okay",
80
77
  "preferences": "Preferences",
78
+ "recovery": "Recovery",
79
+ "recovery_method_disabled": "This recovery method is disabled and can't be used.",
81
80
  "register": "Register",
82
81
  "rename": "Rename",
83
82
  "sessions": "Sessions",
@@ -85,7 +84,7 @@
85
84
  "success": "Success",
86
85
  "unknown": "Unknown",
87
86
  "unnamed": "Unnamed",
88
- "user_display_name": "Name",
87
+ "user_display_name": "Display name",
89
88
  "username": "Username",
90
89
  "yes": "Yes"
91
90
  },
@@ -121,8 +120,11 @@
121
120
  "toast_updated": "Profile picture updated"
122
121
  },
123
122
  "preferences": "Preferences",
123
+ "remove_email": "Remove email",
124
+ "remove_email_confirm": "Are you sure you want to remove your recovery email?",
124
125
  "sessions": "Sessions",
125
126
  "title": "Your Account",
127
+ "toast_username_updated": "Username updated",
126
128
  "user_id": "User ID",
127
129
  "user_id_hint": "This is your UUID. It can't be changed.",
128
130
  "verification_sent": "Verification email sent",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/client",
3
- "version": "0.33.5",
3
+ "version": "0.35.0",
4
4
  "author": "James Prevett <jp@jamespre.dev>",
5
5
  "funding": {
6
6
  "type": "individual",
@@ -49,7 +49,7 @@
49
49
  "build": "tsc"
50
50
  },
51
51
  "peerDependencies": {
52
- "@axium/core": ">=0.36.1",
52
+ "@axium/core": ">=0.37.0",
53
53
  "ioium": "^1.7.0",
54
54
  "semver": "^7.7.4",
55
55
  "svelte": "^5.36.0",
@@ -41,7 +41,7 @@
41
41
  margin-left: 1em;
42
42
  }
43
43
 
44
- > :nth-child(2) {
44
+ > :nth-child(2):not(.InlineEdit) {
45
45
  text-overflow: ellipsis;
46
46
  overflow: hidden;
47
47
  }