@astronox/core 0.2.1 → 0.2.3
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/package.json +7 -1
- package/src/admin/cache.ts +217 -0
- package/src/admin/contracts.ts +28 -0
- package/src/admin/index.ts +3 -0
- package/src/admin/module.ts +28 -0
- package/src/auth/contracts.ts +53 -0
- package/src/auth/firebase/client.ts +83 -0
- package/src/auth/firebase/contracts.ts +8 -0
- package/src/auth/firebase/index.ts +1 -0
- package/src/auth/index.ts +2 -0
- package/src/auth/module.ts +99 -0
- package/src/client/formSubmission.ts +177 -0
- package/src/client/index.ts +4 -0
- package/src/client/preferences.ts +400 -0
- package/src/components/actions/Button/Button.astro +42 -6
- package/src/components/actions/Button/Button.css +19 -0
- package/src/components/actions/IdentityProviderAction/IdentityProviderAction.astro +35 -0
- package/src/components/actions/ScrollToTop/ScrollToTop.css +11 -0
- package/src/components/content/CardBody/CardBody.astro +3 -0
- package/src/components/content/CardBody/CardBody.css +16 -2
- package/src/components/content/CardFooter/CardFooter.astro +9 -0
- package/src/components/content/CardFooter/CardFooter.css +45 -3
- package/src/components/content/CardLink/CardLink.astro +12 -3
- package/src/components/content/CardLink/CardLink.css +32 -0
- package/src/components/content/MarkdownContent/MarkdownContent.css +14 -4
- package/src/components/content/MarkdownContent/MarkdownInline.astro +1 -1
- package/src/components/content/MarkdownContent/contracts.ts +1 -1
- package/src/components/content/MarkdownContent/markdownContentRenderer.ts +552 -0
- package/src/components/data-display/TextPair/TextPair.css +8 -2
- package/src/components/feedback/PreferenceBootstrap/PreferenceBootstrap.astro +403 -0
- package/src/components/forms/Checkbox/Checkbox.astro +6 -0
- package/src/components/forms/ChoiceGroup/ChoiceGroup.astro +57 -0
- package/src/components/forms/ChoiceGroup/ChoiceGroup.css +108 -0
- package/src/components/forms/ChoiceGroup/contracts.ts +9 -0
- package/src/components/forms/DateTimeInput/DateTimeInput.astro +14 -0
- package/src/components/forms/PreparedFormBootstrap/PreparedFormBootstrap.astro +203 -0
- package/src/components/forms/PreparedFormBootstrap/contracts.ts +15 -0
- package/src/components/forms/TextInput/TextInput.astro +7 -1
- package/src/components/forms/Textarea/Textarea.astro +35 -0
- package/src/components/forms/Textarea/Textarea.css +62 -0
- package/src/components/icons/BackIcon.astro +26 -0
- package/src/components/icons/DeleteIcon.astro +22 -0
- package/src/components/icons/StatusIndicatorIcon.astro +16 -0
- package/src/components/index.ts +18 -0
- package/src/components/media/CanvasFrame/CanvasFrame.astro +1 -17
- package/src/components/media/EmbedFrame/EmbedFrame.astro +1 -19
- package/src/components/media/EmbedFrame/EmbedFrame.css +0 -2
- package/src/components/navigation/Breadcrumbs/Breadcrumbs.astro +5 -1
- package/src/components/navigation/Breadcrumbs/Breadcrumbs.css +12 -17
- package/src/components/navigation/Footer/Footer.astro +102 -0
- package/src/components/navigation/Footer/Footer.css +155 -0
- package/src/components/navigation/Footer/contracts.ts +17 -0
- package/src/components/navigation/SubNavigation/SubNavigation.css +0 -5
- package/src/composition/Grid/Grid.css +16 -0
- package/src/composition/SurfaceGroup/SurfaceGroup.css +21 -7
- package/src/composition/SurfaceGroup/stickySurfaceGroupController.ts +3 -0
- package/src/data/firestore/client/contracts.ts +78 -0
- package/src/data/firestore/client/firebase.ts +56 -0
- package/src/data/firestore/client/index.ts +3 -0
- package/src/data/firestore/client/repository.ts +111 -0
- package/src/display/contracts.ts +44 -1
- package/src/display/index.ts +1 -0
- package/src/display/tableProgressive.ts +62 -0
- package/src/layouts/BaseLayout.astro +8 -1
- package/src/patterns/Combobox/Combobox.astro +23 -0
- package/src/patterns/Combobox/comboboxController.ts +69 -0
- package/src/patterns/Fullscreen/FullscreenControl.astro +4 -14
- package/src/patterns/Fullscreen/FullscreenControl.css +0 -26
- package/src/patterns/Modal/Modal.astro +1 -1
- package/src/patterns/Modal/Modal.css +38 -1
- package/src/patterns/Table/Table.astro +122 -7
- package/src/patterns/Table/TableBody.astro +8 -0
- package/src/patterns/Table/TableBody.css +3 -3
- package/src/patterns/Table/TableCell.css +11 -1
- package/src/patterns/Table/TableCellValue.astro +31 -0
- package/src/patterns/Table/TableFooter.astro +3 -1
- package/src/patterns/Table/TableHeader.astro +1 -0
- package/src/patterns/Table/TablePagination.astro +63 -47
- package/src/patterns/Table/TableSkeleton.astro +18 -22
- package/src/patterns/Table/TableSkeleton.css +30 -5
- package/src/patterns/Table/tableClientController.ts +253 -0
- package/src/patterns/Table/tableFilterController.ts +23 -31
- package/src/patterns/Table/tableProgressiveController.ts +615 -227
- package/src/patterns/Table/tableSortController.ts +25 -7
- package/src/styles/base.css +24 -7
- package/src/styles/interaction.css +16 -0
- package/src/styles/style.css +3 -0
- package/src/styles/tokens.css +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astronox/core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Reusable Astro components, patterns, styles, and infrastructure for AstroNox sites.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -30,10 +30,15 @@
|
|
|
30
30
|
"exports": {
|
|
31
31
|
".": "./src/index.ts",
|
|
32
32
|
"./client": "./src/client/index.ts",
|
|
33
|
+
"./admin": "./src/admin/index.ts",
|
|
34
|
+
"./auth": "./src/auth/index.ts",
|
|
35
|
+
"./auth/firebase": "./src/auth/firebase/index.ts",
|
|
36
|
+
"./auth/firebase/client": "./src/auth/firebase/client.ts",
|
|
33
37
|
"./components": "./src/components/index.ts",
|
|
34
38
|
"./composition": "./src/composition/index.ts",
|
|
35
39
|
"./config": "./src/config/index.ts",
|
|
36
40
|
"./data": "./src/data/index.ts",
|
|
41
|
+
"./data/firestore/client": "./src/data/firestore/client/index.ts",
|
|
37
42
|
"./display": "./src/display/index.ts",
|
|
38
43
|
"./i18n": "./src/i18n/index.ts",
|
|
39
44
|
"./layouts": "./src/layouts/index.ts",
|
|
@@ -62,6 +67,7 @@
|
|
|
62
67
|
"astro": "^7.0.0"
|
|
63
68
|
},
|
|
64
69
|
"dependencies": {
|
|
70
|
+
"firebase": "^12.0.0",
|
|
65
71
|
"zod": "4.4.3"
|
|
66
72
|
},
|
|
67
73
|
"devDependencies": {
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { AdminPage } from './contracts';
|
|
2
|
+
|
|
3
|
+
export type AdminResourceCacheSerializer<TItem, TStored> = {
|
|
4
|
+
parse(value: unknown): TItem | null;
|
|
5
|
+
serialize(item: TItem): TStored;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type AdminResourceCacheConfig<TItem, TStored> = {
|
|
9
|
+
collectionKey: string;
|
|
10
|
+
editorKey: string;
|
|
11
|
+
getKey(item: TItem): string;
|
|
12
|
+
serializer: AdminResourceCacheSerializer<TItem, TStored>;
|
|
13
|
+
sort(items: readonly TItem[]): TItem[];
|
|
14
|
+
ttlMs?: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type StoredPage<TStored> = {
|
|
18
|
+
expiresAt: number;
|
|
19
|
+
items: TStored[];
|
|
20
|
+
nextCursor?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type StoredEditor<TStored> = {
|
|
24
|
+
actorId: string;
|
|
25
|
+
expiresAt: number;
|
|
26
|
+
item: TStored;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const localCursorPrefix = 'cache:';
|
|
30
|
+
|
|
31
|
+
export function createAdminResourceCache<TItem, TStored>({
|
|
32
|
+
collectionKey,
|
|
33
|
+
editorKey,
|
|
34
|
+
getKey,
|
|
35
|
+
serializer,
|
|
36
|
+
sort,
|
|
37
|
+
ttlMs = 10 * 60 * 1000,
|
|
38
|
+
}: AdminResourceCacheConfig<TItem, TStored>) {
|
|
39
|
+
const collectionCacheKey = (actorId: string) =>
|
|
40
|
+
`${collectionKey}:${encodeURIComponent(actorId)}`;
|
|
41
|
+
const editorCacheKey = (id: string) =>
|
|
42
|
+
`${editorKey}:${encodeURIComponent(id)}`;
|
|
43
|
+
|
|
44
|
+
const readSnapshot = (actorId: string): AdminPage<TItem> | null => {
|
|
45
|
+
try {
|
|
46
|
+
const key = collectionCacheKey(actorId);
|
|
47
|
+
const raw = sessionStorage.getItem(key);
|
|
48
|
+
if (!raw) return null;
|
|
49
|
+
const stored = JSON.parse(raw) as Partial<StoredPage<TStored>>;
|
|
50
|
+
if (
|
|
51
|
+
typeof stored.expiresAt !== 'number' ||
|
|
52
|
+
stored.expiresAt <= Date.now() ||
|
|
53
|
+
!Array.isArray(stored.items)
|
|
54
|
+
) {
|
|
55
|
+
sessionStorage.removeItem(key);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const items: TItem[] = [];
|
|
60
|
+
for (const value of stored.items) {
|
|
61
|
+
const item = serializer.parse(value);
|
|
62
|
+
if (!item) {
|
|
63
|
+
sessionStorage.removeItem(key);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
items.push(item);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
items: sort(items),
|
|
71
|
+
nextCursor:
|
|
72
|
+
typeof stored.nextCursor === 'string' ? stored.nextCursor : undefined,
|
|
73
|
+
};
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const write = (actorId: string, page: AdminPage<TItem>): void => {
|
|
80
|
+
try {
|
|
81
|
+
const payload: StoredPage<TStored> = {
|
|
82
|
+
expiresAt: Date.now() + ttlMs,
|
|
83
|
+
items: sort(page.items).map(serializer.serialize),
|
|
84
|
+
nextCursor: page.nextCursor,
|
|
85
|
+
};
|
|
86
|
+
sessionStorage.setItem(
|
|
87
|
+
collectionCacheKey(actorId),
|
|
88
|
+
JSON.stringify(payload),
|
|
89
|
+
);
|
|
90
|
+
} catch {
|
|
91
|
+
// Storage is an optimization and must never block administration.
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
editorKey,
|
|
97
|
+
isLocalCursor(cursor?: string): boolean {
|
|
98
|
+
return cursor?.startsWith(localCursorPrefix) === true;
|
|
99
|
+
},
|
|
100
|
+
read(
|
|
101
|
+
actorId: string,
|
|
102
|
+
input?: { cursor?: string; limit?: number },
|
|
103
|
+
): AdminPage<TItem> | null {
|
|
104
|
+
const cached = readSnapshot(actorId);
|
|
105
|
+
if (!cached) return null;
|
|
106
|
+
if (input?.cursor && !input.cursor.startsWith(localCursorPrefix)) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const offset = input?.cursor
|
|
110
|
+
? Number.parseInt(input.cursor.slice(localCursorPrefix.length), 10)
|
|
111
|
+
: 0;
|
|
112
|
+
if (!Number.isSafeInteger(offset) || offset < 0) return null;
|
|
113
|
+
const limit = Math.max(1, Math.floor(input?.limit ?? 25));
|
|
114
|
+
const end = Math.min(cached.items.length, offset + limit);
|
|
115
|
+
return {
|
|
116
|
+
items: cached.items.slice(offset, end),
|
|
117
|
+
nextCursor:
|
|
118
|
+
end < cached.items.length
|
|
119
|
+
? `${localCursorPrefix}${end}`
|
|
120
|
+
: cached.nextCursor,
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
find(actorId: string, id: string): TItem | null {
|
|
124
|
+
return (
|
|
125
|
+
readSnapshot(actorId)?.items.find((item) => getKey(item) === id) ??
|
|
126
|
+
null
|
|
127
|
+
);
|
|
128
|
+
},
|
|
129
|
+
readEditor(actorId: string, id: string): TItem | null {
|
|
130
|
+
try {
|
|
131
|
+
const key = editorCacheKey(id);
|
|
132
|
+
const raw = sessionStorage.getItem(key);
|
|
133
|
+
if (!raw) return null;
|
|
134
|
+
const stored = JSON.parse(raw) as Partial<StoredEditor<TStored>>;
|
|
135
|
+
if (
|
|
136
|
+
stored.actorId !== actorId ||
|
|
137
|
+
typeof stored.expiresAt !== 'number' ||
|
|
138
|
+
stored.expiresAt <= Date.now()
|
|
139
|
+
) {
|
|
140
|
+
sessionStorage.removeItem(key);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return serializer.parse(stored.item);
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
write,
|
|
149
|
+
append(actorId: string, page: AdminPage<TItem>): void {
|
|
150
|
+
const cached = readSnapshot(actorId);
|
|
151
|
+
if (!cached) return;
|
|
152
|
+
const items = new Map(cached.items.map((item) => [getKey(item), item]));
|
|
153
|
+
page.items.forEach((item) => items.set(getKey(item), item));
|
|
154
|
+
write(actorId, {
|
|
155
|
+
items: [...items.values()],
|
|
156
|
+
nextCursor: page.nextCursor,
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
upsert(actorId: string, item: TItem): void {
|
|
160
|
+
const cached = readSnapshot(actorId);
|
|
161
|
+
if (!cached) return;
|
|
162
|
+
const items = new Map(
|
|
163
|
+
cached.items.map((candidate) => [getKey(candidate), candidate]),
|
|
164
|
+
);
|
|
165
|
+
items.set(getKey(item), item);
|
|
166
|
+
write(actorId, {
|
|
167
|
+
items: [...items.values()],
|
|
168
|
+
nextCursor: cached.nextCursor,
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
remove(actorId: string, id: string): void {
|
|
172
|
+
const cached = readSnapshot(actorId);
|
|
173
|
+
if (cached) {
|
|
174
|
+
write(actorId, {
|
|
175
|
+
items: cached.items.filter((item) => getKey(item) !== id),
|
|
176
|
+
nextCursor: cached.nextCursor,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
sessionStorage.removeItem(editorCacheKey(id));
|
|
181
|
+
} catch {
|
|
182
|
+
// Cleanup is best effort.
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
writeEditor(actorId: string, item: TItem): boolean {
|
|
186
|
+
try {
|
|
187
|
+
const payload: StoredEditor<TStored> = {
|
|
188
|
+
actorId,
|
|
189
|
+
expiresAt: Date.now() + ttlMs,
|
|
190
|
+
item: serializer.serialize(item),
|
|
191
|
+
};
|
|
192
|
+
sessionStorage.setItem(
|
|
193
|
+
editorCacheKey(getKey(item)),
|
|
194
|
+
JSON.stringify(payload),
|
|
195
|
+
);
|
|
196
|
+
return true;
|
|
197
|
+
} catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
clear(actorId: string): void {
|
|
202
|
+
try {
|
|
203
|
+
sessionStorage.removeItem(collectionCacheKey(actorId));
|
|
204
|
+
for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
|
|
205
|
+
const key = sessionStorage.key(index);
|
|
206
|
+
if (!key?.startsWith(`${editorKey}:`)) continue;
|
|
207
|
+
const raw = sessionStorage.getItem(key);
|
|
208
|
+
if (!raw) continue;
|
|
209
|
+
const stored = JSON.parse(raw) as Partial<StoredEditor<TStored>>;
|
|
210
|
+
if (stored.actorId === actorId) sessionStorage.removeItem(key);
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// Cleanup is best effort.
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
} as const;
|
|
217
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AuthActor, AuthSession } from '../auth';
|
|
2
|
+
|
|
3
|
+
export type AdminPage<TItem, TCursor = string> = {
|
|
4
|
+
items: readonly TItem[];
|
|
5
|
+
nextCursor?: TCursor;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export interface AdminResource<
|
|
9
|
+
TItem,
|
|
10
|
+
TCreateInput,
|
|
11
|
+
TUpdateInput = TCreateInput,
|
|
12
|
+
TIndexInput = void,
|
|
13
|
+
> {
|
|
14
|
+
getIndex(input: TIndexInput): Promise<AdminPage<TItem>>;
|
|
15
|
+
getDetail(input: { id: string }): Promise<TItem | null>;
|
|
16
|
+
create(input: TCreateInput, actor: AuthActor): Promise<TItem>;
|
|
17
|
+
update(
|
|
18
|
+
input: { id: string; value: TUpdateInput },
|
|
19
|
+
actor: AuthActor,
|
|
20
|
+
): Promise<TItem>;
|
|
21
|
+
delete(input: { id: string }, actor: AuthActor): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type AdminAccess<TResources extends Record<string, unknown>> = {
|
|
25
|
+
actor: AuthActor;
|
|
26
|
+
resources: TResources;
|
|
27
|
+
session: AuthSession;
|
|
28
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AuthModule } from '../auth';
|
|
2
|
+
import type { AdminAccess } from './contracts';
|
|
3
|
+
|
|
4
|
+
export function createAdminModule<
|
|
5
|
+
TCredential,
|
|
6
|
+
TCapability extends string,
|
|
7
|
+
TResources extends Record<string, unknown>,
|
|
8
|
+
>({
|
|
9
|
+
auth,
|
|
10
|
+
capability,
|
|
11
|
+
resources,
|
|
12
|
+
}: {
|
|
13
|
+
auth: AuthModule<TCredential, TCapability>;
|
|
14
|
+
capability: TCapability;
|
|
15
|
+
resources: TResources;
|
|
16
|
+
}) {
|
|
17
|
+
return {
|
|
18
|
+
async getActor(token?: string | null) {
|
|
19
|
+
return (await auth.getSession(token))?.actor ?? null;
|
|
20
|
+
},
|
|
21
|
+
async requireAccess(
|
|
22
|
+
token?: string | null,
|
|
23
|
+
): Promise<AdminAccess<TResources>> {
|
|
24
|
+
const session = await auth.requireCapability(token, capability);
|
|
25
|
+
return { actor: session.actor, resources, session };
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type AuthClaims = Readonly<Record<string, unknown>>;
|
|
2
|
+
|
|
3
|
+
export type AuthActor = {
|
|
4
|
+
id: string;
|
|
5
|
+
claims: AuthClaims;
|
|
6
|
+
displayName?: string;
|
|
7
|
+
email?: string;
|
|
8
|
+
imageUrl?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type AuthSession = {
|
|
12
|
+
actor: AuthActor;
|
|
13
|
+
expiresAt: Date;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type AuthSessionGrant = AuthSession & {
|
|
17
|
+
token: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface IdentityProvider<TCredential = string> {
|
|
21
|
+
authenticate(credential: TCredential): Promise<AuthActor>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SessionProvider<TCredential = string> {
|
|
25
|
+
create(
|
|
26
|
+
credential: TCredential,
|
|
27
|
+
actor: AuthActor,
|
|
28
|
+
): Promise<AuthSessionGrant>;
|
|
29
|
+
get(token: string): Promise<AuthSession | null>;
|
|
30
|
+
revoke(token: string): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AuthorizationPolicy<TCapability extends string = string> {
|
|
34
|
+
allows(actor: AuthActor, capability: TCapability): boolean | Promise<boolean>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class AuthenticationError extends Error {
|
|
38
|
+
readonly code = 'AUTHENTICATION_REQUIRED';
|
|
39
|
+
|
|
40
|
+
constructor(message = 'Authentication is required') {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = 'AuthenticationError';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class AuthorizationError extends Error {
|
|
47
|
+
readonly code = 'AUTHORIZATION_DENIED';
|
|
48
|
+
|
|
49
|
+
constructor(message = 'Access is denied') {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = 'AuthorizationError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { getApps, initializeApp, type FirebaseApp } from 'firebase/app';
|
|
2
|
+
import {
|
|
3
|
+
browserLocalPersistence,
|
|
4
|
+
browserPopupRedirectResolver,
|
|
5
|
+
GoogleAuthProvider,
|
|
6
|
+
getAuth,
|
|
7
|
+
initializeAuth,
|
|
8
|
+
onAuthStateChanged,
|
|
9
|
+
signInWithPopup,
|
|
10
|
+
signOut,
|
|
11
|
+
type Auth,
|
|
12
|
+
type User,
|
|
13
|
+
} from 'firebase/auth';
|
|
14
|
+
import type { AuthActor } from '../contracts';
|
|
15
|
+
import type { FirebaseClientConfig } from './contracts';
|
|
16
|
+
|
|
17
|
+
export type FirebaseGoogleAuthClient = {
|
|
18
|
+
observe(
|
|
19
|
+
listener: (actor: AuthActor | null) => void,
|
|
20
|
+
onError?: (reason: unknown) => void,
|
|
21
|
+
): () => void;
|
|
22
|
+
signIn(): Promise<AuthActor>;
|
|
23
|
+
signOut(): Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function initializeBrowserAuth(app: FirebaseApp): Auth {
|
|
27
|
+
try {
|
|
28
|
+
return initializeAuth(app, {
|
|
29
|
+
persistence: browserLocalPersistence,
|
|
30
|
+
popupRedirectResolver: browserPopupRedirectResolver,
|
|
31
|
+
});
|
|
32
|
+
} catch (reason) {
|
|
33
|
+
if (
|
|
34
|
+
typeof reason === 'object' &&
|
|
35
|
+
reason !== null &&
|
|
36
|
+
'code' in reason &&
|
|
37
|
+
reason.code === 'auth/already-initialized'
|
|
38
|
+
) {
|
|
39
|
+
return getAuth(app);
|
|
40
|
+
}
|
|
41
|
+
throw reason;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toAuthActor(user: User): AuthActor {
|
|
46
|
+
return {
|
|
47
|
+
id: user.uid,
|
|
48
|
+
claims: {},
|
|
49
|
+
displayName: user.displayName ?? undefined,
|
|
50
|
+
email: user.email ?? undefined,
|
|
51
|
+
imageUrl: user.photoURL ?? undefined,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createFirebaseGoogleAuthClient(
|
|
56
|
+
config: FirebaseClientConfig,
|
|
57
|
+
): FirebaseGoogleAuthClient {
|
|
58
|
+
const app =
|
|
59
|
+
getApps().find((candidate) => candidate.name === config.projectId) ??
|
|
60
|
+
initializeApp(config, config.projectId);
|
|
61
|
+
const auth = initializeBrowserAuth(app);
|
|
62
|
+
const provider = new GoogleAuthProvider();
|
|
63
|
+
provider.setCustomParameters({ prompt: 'select_account' });
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
observe(listener, onError) {
|
|
67
|
+
return onAuthStateChanged(
|
|
68
|
+
auth,
|
|
69
|
+
(user) => {
|
|
70
|
+
listener(user ? toAuthActor(user) : null);
|
|
71
|
+
},
|
|
72
|
+
onError,
|
|
73
|
+
);
|
|
74
|
+
},
|
|
75
|
+
async signIn() {
|
|
76
|
+
const result = await signInWithPopup(auth, provider);
|
|
77
|
+
return toAuthActor(result.user);
|
|
78
|
+
},
|
|
79
|
+
async signOut() {
|
|
80
|
+
await signOut(auth);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './contracts';
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AuthenticationError,
|
|
3
|
+
AuthorizationError,
|
|
4
|
+
type AuthActor,
|
|
5
|
+
type AuthSession,
|
|
6
|
+
type AuthSessionGrant,
|
|
7
|
+
type AuthorizationPolicy,
|
|
8
|
+
type IdentityProvider,
|
|
9
|
+
type SessionProvider,
|
|
10
|
+
} from './contracts';
|
|
11
|
+
|
|
12
|
+
export type AuthModule<TCredential, TCapability extends string> = {
|
|
13
|
+
createSession(credential: TCredential): Promise<AuthSessionGrant>;
|
|
14
|
+
createAuthorizedSession(
|
|
15
|
+
credential: TCredential,
|
|
16
|
+
capability: TCapability,
|
|
17
|
+
): Promise<AuthSessionGrant>;
|
|
18
|
+
getSession(token?: string | null): Promise<AuthSession | null>;
|
|
19
|
+
requireCapability(
|
|
20
|
+
token: string | null | undefined,
|
|
21
|
+
capability: TCapability,
|
|
22
|
+
): Promise<AuthSession>;
|
|
23
|
+
revokeSession(token?: string | null): Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function createAuthModule<TCredential, TCapability extends string>({
|
|
27
|
+
authorizationPolicy,
|
|
28
|
+
identityProvider,
|
|
29
|
+
sessionProvider,
|
|
30
|
+
}: {
|
|
31
|
+
authorizationPolicy: AuthorizationPolicy<TCapability>;
|
|
32
|
+
identityProvider: IdentityProvider<TCredential>;
|
|
33
|
+
sessionProvider: SessionProvider<TCredential>;
|
|
34
|
+
}): AuthModule<TCredential, TCapability> {
|
|
35
|
+
async function authenticate(credential: TCredential): Promise<AuthActor> {
|
|
36
|
+
return identityProvider.authenticate(credential);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function authorize(
|
|
40
|
+
actor: AuthActor,
|
|
41
|
+
capability: TCapability,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
if (!(await authorizationPolicy.allows(actor, capability))) {
|
|
44
|
+
throw new AuthorizationError();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
async createSession(credential) {
|
|
50
|
+
const actor = await authenticate(credential);
|
|
51
|
+
return sessionProvider.create(credential, actor);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
async createAuthorizedSession(credential, capability) {
|
|
55
|
+
const actor = await authenticate(credential);
|
|
56
|
+
await authorize(actor, capability);
|
|
57
|
+
return sessionProvider.create(credential, actor);
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async getSession(token) {
|
|
61
|
+
if (!token) return null;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
return await sessionProvider.get(token);
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
async requireCapability(token, capability) {
|
|
71
|
+
if (!token) throw new AuthenticationError();
|
|
72
|
+
|
|
73
|
+
const session = await sessionProvider.get(token);
|
|
74
|
+
if (!session) throw new AuthenticationError();
|
|
75
|
+
await authorize(session.actor, capability);
|
|
76
|
+
return session;
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
async revokeSession(token) {
|
|
80
|
+
if (!token) return;
|
|
81
|
+
await sessionProvider.revoke(token);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function requireClaim<TCapability extends string>(
|
|
87
|
+
claim: string,
|
|
88
|
+
expected: unknown,
|
|
89
|
+
capability?: TCapability,
|
|
90
|
+
): AuthorizationPolicy<TCapability> {
|
|
91
|
+
return {
|
|
92
|
+
allows(actor: AuthActor, requestedCapability: TCapability) {
|
|
93
|
+
if (capability !== undefined && requestedCapability !== capability) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
return actor.claims[claim] === expected;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|