@broberg/app-shell 0.3.1 → 0.5.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/README.md +72 -0
- package/css/app-shell.css +36 -0
- package/dist/index.cjs +93 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +93 -1
- package/dist/index.d.ts +93 -1
- package/dist/index.js +88 -3
- package/dist/index.js.map +1 -1
- package/dist/preact.cjs +252 -2
- package/dist/preact.cjs.map +1 -1
- package/dist/preact.d.cts +125 -2
- package/dist/preact.d.ts +125 -2
- package/dist/preact.js +245 -3
- package/dist/preact.js.map +1 -1
- package/package.json +1 -1
package/dist/preact.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { JSX, ComponentChildren } from 'preact';
|
|
2
2
|
import { NotificationRow } from '@broberg/notifications/types';
|
|
3
3
|
export { NotificationRow } from '@broberg/notifications/types';
|
|
4
4
|
|
|
@@ -101,6 +101,58 @@ declare function createMemoryNotificationSource(initial?: NotificationRow[]): No
|
|
|
101
101
|
};
|
|
102
102
|
/** "nu", "5 min", "3 t", "i går", "4 d" (da) or the English equivalents. */
|
|
103
103
|
declare function relativeTime(ts: string | number, lang: "da" | "en", now?: number): string;
|
|
104
|
+
/** What every account call resolves to — the profile AS THE SERVER NOW HOLDS IT. */
|
|
105
|
+
interface AccountProfile {
|
|
106
|
+
sub: string;
|
|
107
|
+
name: string | null;
|
|
108
|
+
picture: string | null;
|
|
109
|
+
email: string | null;
|
|
110
|
+
/** Broberg ID's own account page (password, passkeys, sessions). */
|
|
111
|
+
account_url: string;
|
|
112
|
+
}
|
|
113
|
+
/** Where the account page gets and saves the profile. Swap it to point anywhere. */
|
|
114
|
+
interface AccountAdapter {
|
|
115
|
+
load(): Promise<AccountProfile>;
|
|
116
|
+
saveName(name: string): Promise<AccountProfile>;
|
|
117
|
+
uploadAvatar(file: Blob): Promise<AccountProfile>;
|
|
118
|
+
removeAvatar(): Promise<AccountProfile>;
|
|
119
|
+
}
|
|
120
|
+
/** A failed account call: the HTTP status and the server's `error` code, if any. */
|
|
121
|
+
declare class AccountError extends Error {
|
|
122
|
+
readonly status: number;
|
|
123
|
+
readonly code?: string | undefined;
|
|
124
|
+
constructor(status: number, code?: string | undefined);
|
|
125
|
+
}
|
|
126
|
+
declare const AVATAR_TYPES: readonly ["image/png", "image/jpeg", "image/webp"];
|
|
127
|
+
declare const AVATAR_MAX_BYTES: number;
|
|
128
|
+
/** Checked BEFORE uploading, so a file the server will refuse is never sent. */
|
|
129
|
+
declare function checkAvatarFile(file: {
|
|
130
|
+
size: number;
|
|
131
|
+
type: string;
|
|
132
|
+
}): "tooLarge" | "wrongType" | null;
|
|
133
|
+
/**
|
|
134
|
+
* What a failed call means for the page. `reauth` = the user's session or its
|
|
135
|
+
* scope does not allow the change (401, or 403 insufficient_scope): signing in
|
|
136
|
+
* again is the fix, so the page says that instead of «try again».
|
|
137
|
+
*/
|
|
138
|
+
declare function accountErrorKind(err: unknown): "reauth" | "tooLarge" | "wrongType" | "other";
|
|
139
|
+
/**
|
|
140
|
+
* The default adapter: the app's own backend, shaped after @broberg/sso's
|
|
141
|
+
* `accountRoutes()`.
|
|
142
|
+
*
|
|
143
|
+
* GET {url}/profile → AccountProfile
|
|
144
|
+
* POST {url}/profile {name} → AccountProfile
|
|
145
|
+
* POST {url}/profile/avatar (raw bytes, Content-Type = the image's) → AccountProfile
|
|
146
|
+
* POST {url}/profile/avatar/remove → AccountProfile
|
|
147
|
+
*
|
|
148
|
+
* A non-2xx THROWS an AccountError — a failed save must never look like a
|
|
149
|
+
* saved one.
|
|
150
|
+
*/
|
|
151
|
+
declare function createFetchAccountAdapter(opts?: {
|
|
152
|
+
url?: string;
|
|
153
|
+
fetch?: typeof fetch;
|
|
154
|
+
credentials?: RequestCredentials;
|
|
155
|
+
}): AccountAdapter;
|
|
104
156
|
declare const TEXT: {
|
|
105
157
|
readonly da: {
|
|
106
158
|
readonly openMenu: "Åbn menu";
|
|
@@ -129,6 +181,26 @@ declare const TEXT: {
|
|
|
129
181
|
readonly backdrop: "Baggrund";
|
|
130
182
|
readonly neurons: "Neuroner";
|
|
131
183
|
readonly plain: "Ren";
|
|
184
|
+
readonly name: "Navn";
|
|
185
|
+
readonly email: "E-mail";
|
|
186
|
+
readonly save: "Gem";
|
|
187
|
+
readonly saving: "Gemmer…";
|
|
188
|
+
readonly saved: "Gemt";
|
|
189
|
+
readonly loadingProfile: "Henter profil…";
|
|
190
|
+
readonly couldNotLoadProfile: "Kunne ikke hente din profil.";
|
|
191
|
+
readonly couldNotSave: "Kunne ikke gemme. Prøv igen.";
|
|
192
|
+
readonly reauth: "Log ind igen for at rette";
|
|
193
|
+
readonly signInAgain: "Log ind igen";
|
|
194
|
+
readonly changePicture: "Skift billede";
|
|
195
|
+
readonly removePicture: "Fjern billede";
|
|
196
|
+
readonly confirmRemovePicture: "Fjern dit billede?";
|
|
197
|
+
readonly remove: "Fjern";
|
|
198
|
+
readonly cancel: "Annuller";
|
|
199
|
+
readonly pictureSaved: "Billedet er gemt";
|
|
200
|
+
readonly pictureRemoved: "Billedet er fjernet";
|
|
201
|
+
readonly pictureTooLarge: "Billedet er for stort — højst 2 MB.";
|
|
202
|
+
readonly pictureWrongType: "Vælg et PNG-, JPEG- eller WebP-billede.";
|
|
203
|
+
readonly bidSecurity: "Sikkerhed i Broberg ID";
|
|
132
204
|
};
|
|
133
205
|
readonly en: {
|
|
134
206
|
readonly openMenu: "Open menu";
|
|
@@ -157,6 +229,26 @@ declare const TEXT: {
|
|
|
157
229
|
readonly backdrop: "Backdrop";
|
|
158
230
|
readonly neurons: "Neurons";
|
|
159
231
|
readonly plain: "Plain";
|
|
232
|
+
readonly name: "Name";
|
|
233
|
+
readonly email: "Email";
|
|
234
|
+
readonly save: "Save";
|
|
235
|
+
readonly saving: "Saving…";
|
|
236
|
+
readonly saved: "Saved";
|
|
237
|
+
readonly loadingProfile: "Loading profile…";
|
|
238
|
+
readonly couldNotLoadProfile: "Could not load your profile.";
|
|
239
|
+
readonly couldNotSave: "Could not save. Try again.";
|
|
240
|
+
readonly reauth: "Sign in again to make changes";
|
|
241
|
+
readonly signInAgain: "Sign in again";
|
|
242
|
+
readonly changePicture: "Change picture";
|
|
243
|
+
readonly removePicture: "Remove picture";
|
|
244
|
+
readonly confirmRemovePicture: "Remove your picture?";
|
|
245
|
+
readonly remove: "Remove";
|
|
246
|
+
readonly cancel: "Cancel";
|
|
247
|
+
readonly pictureSaved: "Picture saved";
|
|
248
|
+
readonly pictureRemoved: "Picture removed";
|
|
249
|
+
readonly pictureTooLarge: "The picture is too large — 2 MB at most.";
|
|
250
|
+
readonly pictureWrongType: "Choose a PNG, JPEG or WebP image.";
|
|
251
|
+
readonly bidSecurity: "Security in Broberg ID";
|
|
160
252
|
};
|
|
161
253
|
};
|
|
162
254
|
type Lang = keyof typeof TEXT;
|
|
@@ -340,5 +432,36 @@ interface AppShellProps {
|
|
|
340
432
|
children?: ComponentChildren;
|
|
341
433
|
}
|
|
342
434
|
declare function AppShell(p: AppShellProps): JSX.Element;
|
|
435
|
+
interface AccountPageProps {
|
|
436
|
+
lang: Lang;
|
|
437
|
+
/** Default: `createFetchAccountAdapter()` → /api/account/profile* (@broberg/sso accountRoutes). */
|
|
438
|
+
adapter?: AccountAdapter;
|
|
439
|
+
/**
|
|
440
|
+
* Where «Log ind igen» goes — a FULL page load, so the app's login flow runs.
|
|
441
|
+
* Default `/auth/login?returnTo=<current path>` (no prompt=login — that forces
|
|
442
|
+
* re-proof and is a redirect-loop trap, F084.108). Broberg ID issues
|
|
443
|
+
* no refresh token, so about an hour after login every change answers 401
|
|
444
|
+
* reauth: that is a normal state here, not an error.
|
|
445
|
+
*/
|
|
446
|
+
reauthHref?: string;
|
|
447
|
+
/**
|
|
448
|
+
* F095.5 — called with the profile AS THE SERVER NOW HOLDS IT after every
|
|
449
|
+
* successful change (name saved, picture uploaded, picture removed), so the
|
|
450
|
+
* host can update the shell's user menu at once. Not called on load or on a
|
|
451
|
+
* failed save.
|
|
452
|
+
*/
|
|
453
|
+
onProfileChange?: (profile: AccountProfile) => void;
|
|
454
|
+
}
|
|
455
|
+
/** `/auth/login?returnTo=<this page>` — back here after signing in. */
|
|
456
|
+
declare function defaultReauthHref(): string;
|
|
457
|
+
/**
|
|
458
|
+
* The user's own name and picture, edited inside the app (F095.3).
|
|
459
|
+
*
|
|
460
|
+
* EVERYTHING SHOWN AFTER A SAVE IS THE SERVER'S ANSWER, never what was typed
|
|
461
|
+
* (CLAUDE.md «Et gem-felt SKAL bevises at gemme»): if Broberg ID trims or
|
|
462
|
+
* changes the name, the field shows that. On a failed save the field keeps the
|
|
463
|
+
* typed text, so nothing is lost and the error says why.
|
|
464
|
+
*/
|
|
465
|
+
declare function AccountPage({ lang, adapter, reauthHref, onProfileChange }: AccountPageProps): JSX.Element;
|
|
343
466
|
|
|
344
|
-
export { AppShell, type AppShellProps, Avatar, type Lang, type NavGroup, type NavItem, type Navigate, NotificationBell, type NotificationBellProps, type NotificationSource, PageHeader, type PageTab, PageTabs, type PreactNavGroup, type PreactNavItem, type ShellUser, Sidebar, type SidebarProps, TEXT, TopBar, type TopBarProps, UserMenu, type UserMenuItem, type UserMenuProps, activeNavLabel, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, isExternal, readFlag, readSet, relativeTime, safeNavigate, useIsMobile, writeFlag, writeSet };
|
|
467
|
+
export { AVATAR_MAX_BYTES, AVATAR_TYPES, type AccountAdapter, AccountError, AccountPage, type AccountPageProps, type AccountProfile, AppShell, type AppShellProps, Avatar, type Lang, type NavGroup, type NavItem, type Navigate, NotificationBell, type NotificationBellProps, type NotificationSource, PageHeader, type PageTab, PageTabs, type PreactNavGroup, type PreactNavItem, type ShellUser, Sidebar, type SidebarProps, TEXT, TopBar, type TopBarProps, UserMenu, type UserMenuItem, type UserMenuProps, accountErrorKind, activeNavLabel, badgeLabel, checkAvatarFile, createFetchAccountAdapter, createFetchNotificationSource, createMemoryNotificationSource, defaultReauthHref, initials, isActivePath, isExternal, readFlag, readSet, relativeTime, safeNavigate, useIsMobile, writeFlag, writeSet };
|
package/dist/preact.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { JSX, ComponentChildren } from 'preact';
|
|
2
2
|
import { NotificationRow } from '@broberg/notifications/types';
|
|
3
3
|
export { NotificationRow } from '@broberg/notifications/types';
|
|
4
4
|
|
|
@@ -101,6 +101,58 @@ declare function createMemoryNotificationSource(initial?: NotificationRow[]): No
|
|
|
101
101
|
};
|
|
102
102
|
/** "nu", "5 min", "3 t", "i går", "4 d" (da) or the English equivalents. */
|
|
103
103
|
declare function relativeTime(ts: string | number, lang: "da" | "en", now?: number): string;
|
|
104
|
+
/** What every account call resolves to — the profile AS THE SERVER NOW HOLDS IT. */
|
|
105
|
+
interface AccountProfile {
|
|
106
|
+
sub: string;
|
|
107
|
+
name: string | null;
|
|
108
|
+
picture: string | null;
|
|
109
|
+
email: string | null;
|
|
110
|
+
/** Broberg ID's own account page (password, passkeys, sessions). */
|
|
111
|
+
account_url: string;
|
|
112
|
+
}
|
|
113
|
+
/** Where the account page gets and saves the profile. Swap it to point anywhere. */
|
|
114
|
+
interface AccountAdapter {
|
|
115
|
+
load(): Promise<AccountProfile>;
|
|
116
|
+
saveName(name: string): Promise<AccountProfile>;
|
|
117
|
+
uploadAvatar(file: Blob): Promise<AccountProfile>;
|
|
118
|
+
removeAvatar(): Promise<AccountProfile>;
|
|
119
|
+
}
|
|
120
|
+
/** A failed account call: the HTTP status and the server's `error` code, if any. */
|
|
121
|
+
declare class AccountError extends Error {
|
|
122
|
+
readonly status: number;
|
|
123
|
+
readonly code?: string | undefined;
|
|
124
|
+
constructor(status: number, code?: string | undefined);
|
|
125
|
+
}
|
|
126
|
+
declare const AVATAR_TYPES: readonly ["image/png", "image/jpeg", "image/webp"];
|
|
127
|
+
declare const AVATAR_MAX_BYTES: number;
|
|
128
|
+
/** Checked BEFORE uploading, so a file the server will refuse is never sent. */
|
|
129
|
+
declare function checkAvatarFile(file: {
|
|
130
|
+
size: number;
|
|
131
|
+
type: string;
|
|
132
|
+
}): "tooLarge" | "wrongType" | null;
|
|
133
|
+
/**
|
|
134
|
+
* What a failed call means for the page. `reauth` = the user's session or its
|
|
135
|
+
* scope does not allow the change (401, or 403 insufficient_scope): signing in
|
|
136
|
+
* again is the fix, so the page says that instead of «try again».
|
|
137
|
+
*/
|
|
138
|
+
declare function accountErrorKind(err: unknown): "reauth" | "tooLarge" | "wrongType" | "other";
|
|
139
|
+
/**
|
|
140
|
+
* The default adapter: the app's own backend, shaped after @broberg/sso's
|
|
141
|
+
* `accountRoutes()`.
|
|
142
|
+
*
|
|
143
|
+
* GET {url}/profile → AccountProfile
|
|
144
|
+
* POST {url}/profile {name} → AccountProfile
|
|
145
|
+
* POST {url}/profile/avatar (raw bytes, Content-Type = the image's) → AccountProfile
|
|
146
|
+
* POST {url}/profile/avatar/remove → AccountProfile
|
|
147
|
+
*
|
|
148
|
+
* A non-2xx THROWS an AccountError — a failed save must never look like a
|
|
149
|
+
* saved one.
|
|
150
|
+
*/
|
|
151
|
+
declare function createFetchAccountAdapter(opts?: {
|
|
152
|
+
url?: string;
|
|
153
|
+
fetch?: typeof fetch;
|
|
154
|
+
credentials?: RequestCredentials;
|
|
155
|
+
}): AccountAdapter;
|
|
104
156
|
declare const TEXT: {
|
|
105
157
|
readonly da: {
|
|
106
158
|
readonly openMenu: "Åbn menu";
|
|
@@ -129,6 +181,26 @@ declare const TEXT: {
|
|
|
129
181
|
readonly backdrop: "Baggrund";
|
|
130
182
|
readonly neurons: "Neuroner";
|
|
131
183
|
readonly plain: "Ren";
|
|
184
|
+
readonly name: "Navn";
|
|
185
|
+
readonly email: "E-mail";
|
|
186
|
+
readonly save: "Gem";
|
|
187
|
+
readonly saving: "Gemmer…";
|
|
188
|
+
readonly saved: "Gemt";
|
|
189
|
+
readonly loadingProfile: "Henter profil…";
|
|
190
|
+
readonly couldNotLoadProfile: "Kunne ikke hente din profil.";
|
|
191
|
+
readonly couldNotSave: "Kunne ikke gemme. Prøv igen.";
|
|
192
|
+
readonly reauth: "Log ind igen for at rette";
|
|
193
|
+
readonly signInAgain: "Log ind igen";
|
|
194
|
+
readonly changePicture: "Skift billede";
|
|
195
|
+
readonly removePicture: "Fjern billede";
|
|
196
|
+
readonly confirmRemovePicture: "Fjern dit billede?";
|
|
197
|
+
readonly remove: "Fjern";
|
|
198
|
+
readonly cancel: "Annuller";
|
|
199
|
+
readonly pictureSaved: "Billedet er gemt";
|
|
200
|
+
readonly pictureRemoved: "Billedet er fjernet";
|
|
201
|
+
readonly pictureTooLarge: "Billedet er for stort — højst 2 MB.";
|
|
202
|
+
readonly pictureWrongType: "Vælg et PNG-, JPEG- eller WebP-billede.";
|
|
203
|
+
readonly bidSecurity: "Sikkerhed i Broberg ID";
|
|
132
204
|
};
|
|
133
205
|
readonly en: {
|
|
134
206
|
readonly openMenu: "Open menu";
|
|
@@ -157,6 +229,26 @@ declare const TEXT: {
|
|
|
157
229
|
readonly backdrop: "Backdrop";
|
|
158
230
|
readonly neurons: "Neurons";
|
|
159
231
|
readonly plain: "Plain";
|
|
232
|
+
readonly name: "Name";
|
|
233
|
+
readonly email: "Email";
|
|
234
|
+
readonly save: "Save";
|
|
235
|
+
readonly saving: "Saving…";
|
|
236
|
+
readonly saved: "Saved";
|
|
237
|
+
readonly loadingProfile: "Loading profile…";
|
|
238
|
+
readonly couldNotLoadProfile: "Could not load your profile.";
|
|
239
|
+
readonly couldNotSave: "Could not save. Try again.";
|
|
240
|
+
readonly reauth: "Sign in again to make changes";
|
|
241
|
+
readonly signInAgain: "Sign in again";
|
|
242
|
+
readonly changePicture: "Change picture";
|
|
243
|
+
readonly removePicture: "Remove picture";
|
|
244
|
+
readonly confirmRemovePicture: "Remove your picture?";
|
|
245
|
+
readonly remove: "Remove";
|
|
246
|
+
readonly cancel: "Cancel";
|
|
247
|
+
readonly pictureSaved: "Picture saved";
|
|
248
|
+
readonly pictureRemoved: "Picture removed";
|
|
249
|
+
readonly pictureTooLarge: "The picture is too large — 2 MB at most.";
|
|
250
|
+
readonly pictureWrongType: "Choose a PNG, JPEG or WebP image.";
|
|
251
|
+
readonly bidSecurity: "Security in Broberg ID";
|
|
160
252
|
};
|
|
161
253
|
};
|
|
162
254
|
type Lang = keyof typeof TEXT;
|
|
@@ -340,5 +432,36 @@ interface AppShellProps {
|
|
|
340
432
|
children?: ComponentChildren;
|
|
341
433
|
}
|
|
342
434
|
declare function AppShell(p: AppShellProps): JSX.Element;
|
|
435
|
+
interface AccountPageProps {
|
|
436
|
+
lang: Lang;
|
|
437
|
+
/** Default: `createFetchAccountAdapter()` → /api/account/profile* (@broberg/sso accountRoutes). */
|
|
438
|
+
adapter?: AccountAdapter;
|
|
439
|
+
/**
|
|
440
|
+
* Where «Log ind igen» goes — a FULL page load, so the app's login flow runs.
|
|
441
|
+
* Default `/auth/login?returnTo=<current path>` (no prompt=login — that forces
|
|
442
|
+
* re-proof and is a redirect-loop trap, F084.108). Broberg ID issues
|
|
443
|
+
* no refresh token, so about an hour after login every change answers 401
|
|
444
|
+
* reauth: that is a normal state here, not an error.
|
|
445
|
+
*/
|
|
446
|
+
reauthHref?: string;
|
|
447
|
+
/**
|
|
448
|
+
* F095.5 — called with the profile AS THE SERVER NOW HOLDS IT after every
|
|
449
|
+
* successful change (name saved, picture uploaded, picture removed), so the
|
|
450
|
+
* host can update the shell's user menu at once. Not called on load or on a
|
|
451
|
+
* failed save.
|
|
452
|
+
*/
|
|
453
|
+
onProfileChange?: (profile: AccountProfile) => void;
|
|
454
|
+
}
|
|
455
|
+
/** `/auth/login?returnTo=<this page>` — back here after signing in. */
|
|
456
|
+
declare function defaultReauthHref(): string;
|
|
457
|
+
/**
|
|
458
|
+
* The user's own name and picture, edited inside the app (F095.3).
|
|
459
|
+
*
|
|
460
|
+
* EVERYTHING SHOWN AFTER A SAVE IS THE SERVER'S ANSWER, never what was typed
|
|
461
|
+
* (CLAUDE.md «Et gem-felt SKAL bevises at gemme»): if Broberg ID trims or
|
|
462
|
+
* changes the name, the field shows that. On a failed save the field keeps the
|
|
463
|
+
* typed text, so nothing is lost and the error says why.
|
|
464
|
+
*/
|
|
465
|
+
declare function AccountPage({ lang, adapter, reauthHref, onProfileChange }: AccountPageProps): JSX.Element;
|
|
343
466
|
|
|
344
|
-
export { AppShell, type AppShellProps, Avatar, type Lang, type NavGroup, type NavItem, type Navigate, NotificationBell, type NotificationBellProps, type NotificationSource, PageHeader, type PageTab, PageTabs, type PreactNavGroup, type PreactNavItem, type ShellUser, Sidebar, type SidebarProps, TEXT, TopBar, type TopBarProps, UserMenu, type UserMenuItem, type UserMenuProps, activeNavLabel, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, isExternal, readFlag, readSet, relativeTime, safeNavigate, useIsMobile, writeFlag, writeSet };
|
|
467
|
+
export { AVATAR_MAX_BYTES, AVATAR_TYPES, type AccountAdapter, AccountError, AccountPage, type AccountPageProps, type AccountProfile, AppShell, type AppShellProps, Avatar, type Lang, type NavGroup, type NavItem, type Navigate, NotificationBell, type NotificationBellProps, type NotificationSource, PageHeader, type PageTab, PageTabs, type PreactNavGroup, type PreactNavItem, type ShellUser, Sidebar, type SidebarProps, TEXT, TopBar, type TopBarProps, UserMenu, type UserMenuItem, type UserMenuProps, accountErrorKind, activeNavLabel, badgeLabel, checkAvatarFile, createFetchAccountAdapter, createFetchNotificationSource, createMemoryNotificationSource, defaultReauthHref, initials, isActivePath, isExternal, readFlag, readSet, relativeTime, safeNavigate, useIsMobile, writeFlag, writeSet };
|
package/dist/preact.js
CHANGED
|
@@ -179,6 +179,51 @@ function relativeTime(ts, lang, now = Date.now()) {
|
|
|
179
179
|
if (d === 1) return lang === "da" ? "i g\xE5r" : "yesterday";
|
|
180
180
|
return `${d} d`;
|
|
181
181
|
}
|
|
182
|
+
var AccountError = class extends Error {
|
|
183
|
+
constructor(status, code) {
|
|
184
|
+
super(code ? `HTTP ${status} ${code}` : `HTTP ${status}`);
|
|
185
|
+
this.status = status;
|
|
186
|
+
this.code = code;
|
|
187
|
+
this.name = "AccountError";
|
|
188
|
+
}
|
|
189
|
+
status;
|
|
190
|
+
code;
|
|
191
|
+
};
|
|
192
|
+
var AVATAR_TYPES = ["image/png", "image/jpeg", "image/webp"];
|
|
193
|
+
var AVATAR_MAX_BYTES = 2 * 1024 * 1024;
|
|
194
|
+
function checkAvatarFile(file) {
|
|
195
|
+
if (!AVATAR_TYPES.includes(file.type)) return "wrongType";
|
|
196
|
+
if (file.size > AVATAR_MAX_BYTES) return "tooLarge";
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
function accountErrorKind(err) {
|
|
200
|
+
if (!(err instanceof AccountError)) return "other";
|
|
201
|
+
if (err.status === 401) return "reauth";
|
|
202
|
+
if (err.status === 403 && err.code === "insufficient_scope") return "reauth";
|
|
203
|
+
if (err.status === 413) return "tooLarge";
|
|
204
|
+
if (err.status === 415) return "wrongType";
|
|
205
|
+
return "other";
|
|
206
|
+
}
|
|
207
|
+
function createFetchAccountAdapter(opts = {}) {
|
|
208
|
+
const base = (opts.url ?? "/api/account").replace(/\/+$/, "");
|
|
209
|
+
const call = async (path, init) => {
|
|
210
|
+
const res = await (opts.fetch ?? fetch)(`${base}${path}`, {
|
|
211
|
+
credentials: opts.credentials ?? "same-origin",
|
|
212
|
+
...init,
|
|
213
|
+
headers: { accept: "application/json", ...init?.headers }
|
|
214
|
+
});
|
|
215
|
+
const body = await res.json().catch(() => null);
|
|
216
|
+
if (!res.ok) throw new AccountError(res.status, typeof body?.error === "string" ? body.error : void 0);
|
|
217
|
+
if (!body || typeof body.sub !== "string") throw new AccountError(res.status, "bad_response");
|
|
218
|
+
return body;
|
|
219
|
+
};
|
|
220
|
+
return {
|
|
221
|
+
load: () => call("/profile"),
|
|
222
|
+
saveName: (name) => call("/profile", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name }) }),
|
|
223
|
+
uploadAvatar: (file) => call("/profile/avatar", { method: "POST", headers: { "content-type": file.type }, body: file }),
|
|
224
|
+
removeAvatar: () => call("/profile/avatar/remove", { method: "POST" })
|
|
225
|
+
};
|
|
226
|
+
}
|
|
182
227
|
var TEXT = {
|
|
183
228
|
da: {
|
|
184
229
|
openMenu: "\xC5bn menu",
|
|
@@ -206,7 +251,27 @@ var TEXT = {
|
|
|
206
251
|
layered: "Lag",
|
|
207
252
|
backdrop: "Baggrund",
|
|
208
253
|
neurons: "Neuroner",
|
|
209
|
-
plain: "Ren"
|
|
254
|
+
plain: "Ren",
|
|
255
|
+
name: "Navn",
|
|
256
|
+
email: "E-mail",
|
|
257
|
+
save: "Gem",
|
|
258
|
+
saving: "Gemmer\u2026",
|
|
259
|
+
saved: "Gemt",
|
|
260
|
+
loadingProfile: "Henter profil\u2026",
|
|
261
|
+
couldNotLoadProfile: "Kunne ikke hente din profil.",
|
|
262
|
+
couldNotSave: "Kunne ikke gemme. Pr\xF8v igen.",
|
|
263
|
+
reauth: "Log ind igen for at rette",
|
|
264
|
+
signInAgain: "Log ind igen",
|
|
265
|
+
changePicture: "Skift billede",
|
|
266
|
+
removePicture: "Fjern billede",
|
|
267
|
+
confirmRemovePicture: "Fjern dit billede?",
|
|
268
|
+
remove: "Fjern",
|
|
269
|
+
cancel: "Annuller",
|
|
270
|
+
pictureSaved: "Billedet er gemt",
|
|
271
|
+
pictureRemoved: "Billedet er fjernet",
|
|
272
|
+
pictureTooLarge: "Billedet er for stort \u2014 h\xF8jst 2 MB.",
|
|
273
|
+
pictureWrongType: "V\xE6lg et PNG-, JPEG- eller WebP-billede.",
|
|
274
|
+
bidSecurity: "Sikkerhed i Broberg ID"
|
|
210
275
|
},
|
|
211
276
|
en: {
|
|
212
277
|
openMenu: "Open menu",
|
|
@@ -234,7 +299,27 @@ var TEXT = {
|
|
|
234
299
|
layered: "Layered",
|
|
235
300
|
backdrop: "Backdrop",
|
|
236
301
|
neurons: "Neurons",
|
|
237
|
-
plain: "Plain"
|
|
302
|
+
plain: "Plain",
|
|
303
|
+
name: "Name",
|
|
304
|
+
email: "Email",
|
|
305
|
+
save: "Save",
|
|
306
|
+
saving: "Saving\u2026",
|
|
307
|
+
saved: "Saved",
|
|
308
|
+
loadingProfile: "Loading profile\u2026",
|
|
309
|
+
couldNotLoadProfile: "Could not load your profile.",
|
|
310
|
+
couldNotSave: "Could not save. Try again.",
|
|
311
|
+
reauth: "Sign in again to make changes",
|
|
312
|
+
signInAgain: "Sign in again",
|
|
313
|
+
changePicture: "Change picture",
|
|
314
|
+
removePicture: "Remove picture",
|
|
315
|
+
confirmRemovePicture: "Remove your picture?",
|
|
316
|
+
remove: "Remove",
|
|
317
|
+
cancel: "Cancel",
|
|
318
|
+
pictureSaved: "Picture saved",
|
|
319
|
+
pictureRemoved: "Picture removed",
|
|
320
|
+
pictureTooLarge: "The picture is too large \u2014 2 MB at most.",
|
|
321
|
+
pictureWrongType: "Choose a PNG, JPEG or WebP image.",
|
|
322
|
+
bidSecurity: "Security in Broberg ID"
|
|
238
323
|
}
|
|
239
324
|
};
|
|
240
325
|
var MOBILE_QUERY = "(max-width: 767px)";
|
|
@@ -913,7 +998,164 @@ function AppShell(p) {
|
|
|
913
998
|
] })
|
|
914
999
|
] });
|
|
915
1000
|
}
|
|
1001
|
+
function defaultReauthHref() {
|
|
1002
|
+
const here = typeof location === "undefined" ? "/" : location.pathname + location.search;
|
|
1003
|
+
return `/auth/login?returnTo=${encodeURIComponent(here)}`;
|
|
1004
|
+
}
|
|
1005
|
+
function AccountPage({ lang, adapter, reauthHref, onProfileChange }) {
|
|
1006
|
+
const t = TEXT[lang];
|
|
1007
|
+
const api = useMemo(() => adapter ?? createFetchAccountAdapter(), [adapter]);
|
|
1008
|
+
const [profile, setProfile] = useState(null);
|
|
1009
|
+
const [draft, setDraft] = useState("");
|
|
1010
|
+
const [busy, setBusy] = useState("load");
|
|
1011
|
+
const [notice, setNotice] = useState(null);
|
|
1012
|
+
const [reauth, setReauth] = useState(false);
|
|
1013
|
+
const [confirming, setConfirming] = useState(false);
|
|
1014
|
+
const fileRef = useRef(null);
|
|
1015
|
+
const apply = (p) => {
|
|
1016
|
+
setProfile(p);
|
|
1017
|
+
setDraft(p.name ?? "");
|
|
1018
|
+
};
|
|
1019
|
+
const fail = (err, fallback) => {
|
|
1020
|
+
const kind = accountErrorKind(err);
|
|
1021
|
+
if (kind === "reauth") {
|
|
1022
|
+
setReauth(true);
|
|
1023
|
+
setNotice(null);
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
const text = kind === "tooLarge" ? t.pictureTooLarge : kind === "wrongType" ? t.pictureWrongType : fallback;
|
|
1027
|
+
setNotice({ kind: "error", text });
|
|
1028
|
+
};
|
|
1029
|
+
useEffect(() => {
|
|
1030
|
+
let live = true;
|
|
1031
|
+
setBusy("load");
|
|
1032
|
+
api.load().then(
|
|
1033
|
+
(p) => live && (apply(p), setBusy(null)),
|
|
1034
|
+
(e) => live && (fail(e, t.couldNotLoadProfile), setBusy(null))
|
|
1035
|
+
);
|
|
1036
|
+
return () => {
|
|
1037
|
+
live = false;
|
|
1038
|
+
};
|
|
1039
|
+
}, [api]);
|
|
1040
|
+
const run = async (what, op, okText) => {
|
|
1041
|
+
setBusy(what);
|
|
1042
|
+
setNotice(null);
|
|
1043
|
+
try {
|
|
1044
|
+
const p = await op();
|
|
1045
|
+
apply(p);
|
|
1046
|
+
onProfileChange?.(p);
|
|
1047
|
+
setNotice({ kind: "ok", text: okText });
|
|
1048
|
+
} catch (e) {
|
|
1049
|
+
fail(e, t.couldNotSave);
|
|
1050
|
+
} finally {
|
|
1051
|
+
setBusy(null);
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
const onSaveName = (e) => {
|
|
1055
|
+
e.preventDefault();
|
|
1056
|
+
if (busy || reauth || unchanged) return;
|
|
1057
|
+
void run("name", () => api.saveName(draft), t.saved);
|
|
1058
|
+
};
|
|
1059
|
+
const onFile = (e) => {
|
|
1060
|
+
const input = e.currentTarget;
|
|
1061
|
+
const file = input.files?.[0];
|
|
1062
|
+
input.value = "";
|
|
1063
|
+
if (!file || busy || reauth) return;
|
|
1064
|
+
const bad = checkAvatarFile(file);
|
|
1065
|
+
if (bad) {
|
|
1066
|
+
setNotice({ kind: "error", text: bad === "tooLarge" ? t.pictureTooLarge : t.pictureWrongType });
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
void run("avatar", () => api.uploadAvatar(file), t.pictureSaved);
|
|
1070
|
+
};
|
|
1071
|
+
const onRemove = () => {
|
|
1072
|
+
setConfirming(false);
|
|
1073
|
+
void run("avatar", () => api.removeAvatar(), t.pictureRemoved);
|
|
1074
|
+
};
|
|
1075
|
+
const locked = reauth || busy !== null || profile === null;
|
|
1076
|
+
const unchanged = draft.trim() === (profile?.name ?? "").trim();
|
|
1077
|
+
const user = { name: profile?.name ?? void 0, email: profile?.email ?? void 0, picture: profile?.picture ?? void 0 };
|
|
1078
|
+
return /* @__PURE__ */ jsxs("section", { class: "bas-account", "data-testid": "account-page", "aria-busy": busy !== null, children: [
|
|
1079
|
+
reauth ? /* @__PURE__ */ jsxs("div", { class: "bas-account__note is-reauth", role: "alert", "data-testid": "account-reauth", children: [
|
|
1080
|
+
/* @__PURE__ */ jsx("span", { "data-testid": "account-reauth-text", children: t.reauth }),
|
|
1081
|
+
/* @__PURE__ */ jsx(
|
|
1082
|
+
"button",
|
|
1083
|
+
{
|
|
1084
|
+
type: "button",
|
|
1085
|
+
class: "bas-account__btn is-primary",
|
|
1086
|
+
onClick: () => window.location.assign(reauthHref ?? defaultReauthHref()),
|
|
1087
|
+
"data-testid": "account-reauth-signin",
|
|
1088
|
+
children: t.signInAgain
|
|
1089
|
+
}
|
|
1090
|
+
)
|
|
1091
|
+
] }) : null,
|
|
1092
|
+
notice ? /* @__PURE__ */ jsx(
|
|
1093
|
+
"p",
|
|
1094
|
+
{
|
|
1095
|
+
class: "bas-account__note " + (notice.kind === "ok" ? "is-ok" : "is-error"),
|
|
1096
|
+
role: notice.kind === "ok" ? "status" : "alert",
|
|
1097
|
+
"data-testid": notice.kind === "ok" ? "account-ok" : "account-error",
|
|
1098
|
+
children: notice.text
|
|
1099
|
+
}
|
|
1100
|
+
) : null,
|
|
1101
|
+
busy === "load" ? /* @__PURE__ */ jsx("p", { class: "bas-account__muted", "data-testid": "account-loading", children: t.loadingProfile }) : null,
|
|
1102
|
+
profile ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1103
|
+
/* @__PURE__ */ jsxs("div", { class: "bas-account__who", children: [
|
|
1104
|
+
/* @__PURE__ */ jsx("span", { class: "bas-account__avatar", "data-testid": "account-avatar", "data-picture": profile.picture ?? "", children: /* @__PURE__ */ jsx(Avatar, { user, size: 72 }, profile.picture ?? "none") }),
|
|
1105
|
+
/* @__PURE__ */ jsxs("div", { class: "bas-account__whotext", children: [
|
|
1106
|
+
/* @__PURE__ */ jsx("div", { class: "bas-account__name", "data-testid": "account-name", children: profile.name ?? "" }),
|
|
1107
|
+
/* @__PURE__ */ jsx("div", { class: "bas-account__email", "data-testid": "account-email", children: profile.email ?? "" })
|
|
1108
|
+
] })
|
|
1109
|
+
] }),
|
|
1110
|
+
/* @__PURE__ */ jsxs("div", { class: "bas-account__row", children: [
|
|
1111
|
+
/* @__PURE__ */ jsx(
|
|
1112
|
+
"input",
|
|
1113
|
+
{
|
|
1114
|
+
ref: fileRef,
|
|
1115
|
+
type: "file",
|
|
1116
|
+
accept: AVATAR_TYPES.join(","),
|
|
1117
|
+
hidden: true,
|
|
1118
|
+
tabIndex: -1,
|
|
1119
|
+
onChange: onFile,
|
|
1120
|
+
"data-testid": "account-avatar-file"
|
|
1121
|
+
}
|
|
1122
|
+
),
|
|
1123
|
+
/* @__PURE__ */ jsx("button", { type: "button", class: "bas-account__btn", disabled: locked, onClick: () => fileRef.current?.click(), "data-testid": "account-avatar-upload", children: busy === "avatar" ? t.saving : t.changePicture }),
|
|
1124
|
+
profile.picture && !confirming ? /* @__PURE__ */ jsx("button", { type: "button", class: "bas-account__btn is-quiet", disabled: locked, onClick: () => setConfirming(true), "data-testid": "account-avatar-remove", children: t.removePicture }) : null,
|
|
1125
|
+
profile.picture && confirming ? /* @__PURE__ */ jsxs("span", { class: "bas-account__confirm", role: "group", "data-testid": "account-avatar-remove-confirmation", children: [
|
|
1126
|
+
/* @__PURE__ */ jsx("span", { children: t.confirmRemovePicture }),
|
|
1127
|
+
/* @__PURE__ */ jsx("button", { type: "button", class: "bas-account__btn is-danger", disabled: locked, onClick: onRemove, "data-testid": "account-avatar-remove-confirm", children: t.remove }),
|
|
1128
|
+
/* @__PURE__ */ jsx("button", { type: "button", class: "bas-account__btn is-quiet", onClick: () => setConfirming(false), "data-testid": "account-avatar-remove-cancel", children: t.cancel })
|
|
1129
|
+
] }) : null
|
|
1130
|
+
] }),
|
|
1131
|
+
/* @__PURE__ */ jsxs("form", { class: "bas-account__form", onSubmit: onSaveName, children: [
|
|
1132
|
+
/* @__PURE__ */ jsx("label", { class: "bas-account__label", for: "bas-account-name", children: t.name }),
|
|
1133
|
+
/* @__PURE__ */ jsxs("div", { class: "bas-account__row", children: [
|
|
1134
|
+
/* @__PURE__ */ jsx(
|
|
1135
|
+
"input",
|
|
1136
|
+
{
|
|
1137
|
+
id: "bas-account-name",
|
|
1138
|
+
class: "bas-account__input",
|
|
1139
|
+
type: "text",
|
|
1140
|
+
autocomplete: "name",
|
|
1141
|
+
value: draft,
|
|
1142
|
+
readOnly: reauth,
|
|
1143
|
+
"aria-readonly": reauth,
|
|
1144
|
+
onInput: (e) => {
|
|
1145
|
+
setDraft(e.currentTarget.value);
|
|
1146
|
+
if (notice?.kind === "ok") setNotice(null);
|
|
1147
|
+
},
|
|
1148
|
+
"data-testid": "account-name-input"
|
|
1149
|
+
}
|
|
1150
|
+
),
|
|
1151
|
+
/* @__PURE__ */ jsx("button", { type: "submit", class: "bas-account__btn is-primary", disabled: locked || unchanged, "data-testid": "account-name-save", children: busy === "name" ? t.saving : t.save })
|
|
1152
|
+
] })
|
|
1153
|
+
] }),
|
|
1154
|
+
profile.account_url ? /* @__PURE__ */ jsx(NavLink, { href: profile.account_url, class: "bas-account__link", "data-testid": "account-bid-link", children: t.bidSecurity }) : null
|
|
1155
|
+
] }) : null
|
|
1156
|
+
] });
|
|
1157
|
+
}
|
|
916
1158
|
|
|
917
|
-
export { AppShell, Avatar, NotificationBell, PageHeader, PageTabs, Sidebar, TEXT, TopBar, UserMenu, activeNavLabel, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, isExternal, readFlag, readSet, relativeTime, safeNavigate, useIsMobile, writeFlag, writeSet };
|
|
1159
|
+
export { AVATAR_MAX_BYTES, AVATAR_TYPES, AccountError, AccountPage, AppShell, Avatar, NotificationBell, PageHeader, PageTabs, Sidebar, TEXT, TopBar, UserMenu, accountErrorKind, activeNavLabel, badgeLabel, checkAvatarFile, createFetchAccountAdapter, createFetchNotificationSource, createMemoryNotificationSource, defaultReauthHref, initials, isActivePath, isExternal, readFlag, readSet, relativeTime, safeNavigate, useIsMobile, writeFlag, writeSet };
|
|
918
1160
|
//# sourceMappingURL=preact.js.map
|
|
919
1161
|
//# sourceMappingURL=preact.js.map
|