@buerli.io/backend 0.0.0-stage → 2.0.0-beta.20261016111859
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/CHANGELOG.md +7 -0
- package/README.md +26 -2
- package/build/backend.d.ts +55 -0
- package/build/backend.js +112 -0
- package/build/firebase/config.d.ts +12 -0
- package/build/firebase/config.js +76 -0
- package/build/firebase/firebase.d.ts +30 -0
- package/build/firebase/firebase.js +182 -0
- package/build/firebase/googleRedirect.d.ts +10 -0
- package/build/firebase/googleRedirect.js +139 -0
- package/build/firebase/signInOut.d.ts +11 -0
- package/build/firebase/signInOut.js +132 -0
- package/build/firebase/utils.d.ts +2 -0
- package/build/firebase/utils.js +47 -0
- package/build/index.d.ts +15 -0
- package/build/index.js +14 -0
- package/build/plans.d.ts +288 -0
- package/build/plans.js +134 -0
- package/build/stripe.d.ts +1 -0
- package/build/stripe.js +3 -0
- package/build/types.d.ts +37 -0
- package/build/types.js +1 -0
- package/build/ui/AuthForm.d.ts +25 -0
- package/build/ui/AuthForm.js +198 -0
- package/build/ui/Avatar.d.ts +28 -0
- package/build/ui/Avatar.js +77 -0
- package/build/ui/ClassfileList.d.ts +7 -0
- package/build/ui/ClassfileList.js +39 -0
- package/build/ui/PasswordReset.d.ts +4 -0
- package/build/ui/PasswordReset.js +36 -0
- package/build/ui/Protected.d.ts +4 -0
- package/build/ui/Protected.js +13 -0
- package/build/ui/SignIn.d.ts +4 -0
- package/build/ui/SignIn.js +47 -0
- package/build/ui/SignInOther.d.ts +4 -0
- package/build/ui/SignInOther.js +19 -0
- package/build/ui/SignUp.d.ts +4 -0
- package/build/ui/SignUp.js +68 -0
- package/build/ui/UserInfo.d.ts +2 -0
- package/build/ui/UserInfo.js +67 -0
- package/build/ui/elements/Grid.d.ts +3 -0
- package/build/ui/elements/Grid.js +5 -0
- package/build/ui/elements/LinkButton.d.ts +3 -0
- package/build/ui/elements/LinkButton.js +4 -0
- package/build/ui/elements/Separator.d.ts +12 -0
- package/build/ui/elements/Separator.js +11 -0
- package/build/ui/elements/Title.d.ts +3 -0
- package/build/ui/elements/Title.js +4 -0
- package/build/utils/copyTextToClipboard.d.ts +1 -0
- package/build/utils/copyTextToClipboard.js +29 -0
- package/build/utils/isValidEmail.d.ts +1 -0
- package/build/utils/isValidEmail.js +4 -0
- package/package.json +46 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# @buerli.io/backend
|
|
2
|
+
|
|
3
|
+
## 2.0.0-beta.20261016111859
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- The backend's client is published to npm as `@buerli.io/backend`, released with buerli's packages in their version, in place of the Nexus registry. It works with React 18 and 19, and a page bundles only what it imports (`sideEffects: false`).
|
package/README.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @buerli.io/backend
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The client of ClassCAD's account backend, for web pages: sign-in (email and password, Google, GitHub), the
|
|
4
|
+
account, plans and checkout, and the engine keys an account's plan allows. classcad.ch and buerligons.io are
|
|
5
|
+
built with it.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @buerli.io/backend
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```jsx
|
|
12
|
+
import { AuthForm, useBackend, fetchEngineKey } from '@buerli.io/backend'
|
|
13
|
+
|
|
14
|
+
function Account() {
|
|
15
|
+
const isSignedIn = useBackend(b => b.user.isSignedIn)
|
|
16
|
+
return isSignedIn ? <p>Signed in.</p> : <AuthForm />
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// once signed in: a key for ClassCAD in this page, as the account's plan allows
|
|
20
|
+
const { key } = await fetchEngineKey({ appType: 'wasm', client: 'my-page' })
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
It talks to the backend of the address it is served on: the live one, or the develop backend on localhost and
|
|
24
|
+
the staging sites. React 18 or 19.
|
|
25
|
+
|
|
26
|
+
It is developed in [buerli](https://github.com/awv-informatik/buerli) (`packages/backend`) and released with
|
|
27
|
+
buerli's packages, in their version.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { StoreApi } from 'zustand';
|
|
2
|
+
import { ClassfileInfo, User, UserInfo } from './types';
|
|
3
|
+
export type DeepReadonly<T> = {
|
|
4
|
+
readonly [P in keyof T]: DeepReadonly<T[P]>;
|
|
5
|
+
};
|
|
6
|
+
type StateApi = Readonly<ReturnType<typeof api>>;
|
|
7
|
+
type StateData = DeepReadonly<{
|
|
8
|
+
user: User;
|
|
9
|
+
classfiles: {
|
|
10
|
+
free: ClassfileInfo[];
|
|
11
|
+
enterprise: ClassfileInfo[];
|
|
12
|
+
};
|
|
13
|
+
status: {
|
|
14
|
+
inProgress: {
|
|
15
|
+
downloadCCApp?: boolean;
|
|
16
|
+
fetchFreeClassfilesList?: boolean;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
}>;
|
|
20
|
+
type State = StateData & Readonly<{
|
|
21
|
+
api: StateApi;
|
|
22
|
+
}>;
|
|
23
|
+
declare const api: (set: StoreApi<State>["setState"], get: StoreApi<State>["getState"]) => {
|
|
24
|
+
/**
|
|
25
|
+
* Sets the user information.
|
|
26
|
+
*
|
|
27
|
+
* @param user The partial user information.
|
|
28
|
+
*/
|
|
29
|
+
setUser: (user: Partial<UserInfo> | null) => void;
|
|
30
|
+
fetchFreeClassfilesList: () => Promise<void>;
|
|
31
|
+
/** @deprecated .ccapp packages (class files with the user's ccApp token): see ClassfileList. */
|
|
32
|
+
downloadCCApp: (item: ClassfileInfo) => Promise<void>;
|
|
33
|
+
setInProgress: (inProgress: Partial<StateData["status"]["inProgress"]>) => void;
|
|
34
|
+
firebaseApp: import("@firebase/app").FirebaseApp;
|
|
35
|
+
firebaseAuth: import("@firebase/auth").Auth;
|
|
36
|
+
firebaseStorage: import("@firebase/storage").FirebaseStorage;
|
|
37
|
+
firebaseFunctionsUrl: string;
|
|
38
|
+
fetchFile: (path: string, type?: "" | "arraybuffer" | "blob" | "document" | "json" | "text") => Promise<any>;
|
|
39
|
+
listAllFiles: (path: string) => Promise<import("./types").FileInfo[]>;
|
|
40
|
+
getIdToken: (forceRefresh?: boolean) => Promise<string | undefined>;
|
|
41
|
+
signOut: () => Promise<boolean>;
|
|
42
|
+
signIn: (email: string, password: string) => Promise<boolean | undefined>;
|
|
43
|
+
createUser: (email: string, password: string) => Promise<boolean>;
|
|
44
|
+
sendVerifyEmail: () => Promise<boolean>;
|
|
45
|
+
validateEmail: (email: string) => Promise<boolean>;
|
|
46
|
+
resetPassword: (email: string) => Promise<boolean>;
|
|
47
|
+
fetchCCApp: (classfile: ClassfileInfo) => Promise<ArrayBuffer>;
|
|
48
|
+
getRole: () => Promise<string | undefined | null>;
|
|
49
|
+
getAppKey: (options?: {
|
|
50
|
+
appType?: "native" | "wasm";
|
|
51
|
+
allowedOrigins?: string[];
|
|
52
|
+
}) => Promise<string>;
|
|
53
|
+
};
|
|
54
|
+
declare const useBackend: import("zustand").UseBoundStore<StoreApi<State>>;
|
|
55
|
+
export { useBackend };
|
package/build/backend.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { produce } from 'immer';
|
|
2
|
+
import { create } from 'zustand';
|
|
3
|
+
import { firebaseAPI } from './firebase/firebase';
|
|
4
|
+
import { getClassfileInfo } from './firebase/utils';
|
|
5
|
+
////////////////////////////////////////////////////
|
|
6
|
+
// API
|
|
7
|
+
////////////////////////////////////////////////////
|
|
8
|
+
const _set = (fn) => {
|
|
9
|
+
useBackend.setState(produce(fn));
|
|
10
|
+
};
|
|
11
|
+
const api = (set, get) => ({
|
|
12
|
+
...firebaseAPI,
|
|
13
|
+
/**
|
|
14
|
+
* Sets the user information.
|
|
15
|
+
*
|
|
16
|
+
* @param user The partial user information.
|
|
17
|
+
*/
|
|
18
|
+
setUser: (user) => {
|
|
19
|
+
// Cleanup state if user has logged out
|
|
20
|
+
if (!user) {
|
|
21
|
+
_set(state => void (state.classfiles = { free: [], enterprise: [] }));
|
|
22
|
+
}
|
|
23
|
+
// Set user
|
|
24
|
+
_set(state => {
|
|
25
|
+
state.user.info = user ? { ...state.user?.info, ...user } : null;
|
|
26
|
+
});
|
|
27
|
+
// Set additional user information
|
|
28
|
+
_set(state => {
|
|
29
|
+
const userInfo = get().user.info;
|
|
30
|
+
// There is no need to verify the email if the login provider is for example Github or Google
|
|
31
|
+
const canVerifyEmail = userInfo?.providerData?.map(provider => provider?.providerId).includes('password');
|
|
32
|
+
state.user.hasValidEmail = userInfo?.emailVerified || !canVerifyEmail;
|
|
33
|
+
state.user.isSignedIn = userInfo?.uid ? true : false;
|
|
34
|
+
});
|
|
35
|
+
// Force an update of the user token.
|
|
36
|
+
// This is a workaround to have a valid token on
|
|
37
|
+
// the firebase server after email verification.
|
|
38
|
+
_set(state => {
|
|
39
|
+
firebaseAPI.getIdToken(true).catch(() => {
|
|
40
|
+
// Token refresh failed, but we continue anyway
|
|
41
|
+
});
|
|
42
|
+
state.user.lastTokenUpdate = Date.now();
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
fetchFreeClassfilesList: async () => {
|
|
46
|
+
try {
|
|
47
|
+
get().api.setInProgress({ fetchFreeClassfilesList: true });
|
|
48
|
+
_set(state => {
|
|
49
|
+
state.classfiles.free = [];
|
|
50
|
+
state.classfiles.enterprise = [];
|
|
51
|
+
});
|
|
52
|
+
const files = await firebaseAPI.listAllFiles('classfiles/free/');
|
|
53
|
+
const free = getClassfileInfo(files);
|
|
54
|
+
let ent = [];
|
|
55
|
+
try {
|
|
56
|
+
const rs = ['enterprise', 'admin'];
|
|
57
|
+
const r = await firebaseAPI.getRole();
|
|
58
|
+
if (rs.includes(r || '')) {
|
|
59
|
+
const files1 = await firebaseAPI.listAllFiles('classfiles/enterprise');
|
|
60
|
+
ent = getClassfileInfo(files1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (error) { }
|
|
64
|
+
_set(state => {
|
|
65
|
+
state.classfiles.free = free.sort((a, b) => b.version.code.localeCompare(a.version.code));
|
|
66
|
+
state.classfiles.enterprise = ent.sort((a, b) => b.version.code.localeCompare(a.version.code));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
_set(state => {
|
|
71
|
+
state.classfiles.free = [];
|
|
72
|
+
state.classfiles.enterprise = [];
|
|
73
|
+
});
|
|
74
|
+
console.error(error);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
get().api.setInProgress({ fetchFreeClassfilesList: false });
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
/** @deprecated .ccapp packages (class files with the user's ccApp token): see ClassfileList. */
|
|
81
|
+
downloadCCApp: async (item) => {
|
|
82
|
+
try {
|
|
83
|
+
get().api.setInProgress({ downloadCCApp: true });
|
|
84
|
+
const data = await firebaseAPI.fetchCCApp(item);
|
|
85
|
+
const file = item.fullPath.substring(item.fullPath.lastIndexOf('/') + 1);
|
|
86
|
+
const ext = file.substring(file.lastIndexOf('.'));
|
|
87
|
+
const name = file.replace(ext, '.ccapp');
|
|
88
|
+
const link = document.createElement('a');
|
|
89
|
+
link.href = window.URL.createObjectURL(new Blob([data], { type: 'application/octet-stream' }));
|
|
90
|
+
link.download = name;
|
|
91
|
+
link.click();
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
get().api.setInProgress({ downloadCCApp: false });
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
setInProgress: (inProgress) => {
|
|
98
|
+
_set(state => {
|
|
99
|
+
state.status.inProgress = { ...state.status.inProgress, ...inProgress }; //deepmerge.all<StateData['status']>([state.status, status])
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
////////////////////////////////////////////////////
|
|
104
|
+
// DATA AND HOOKS
|
|
105
|
+
////////////////////////////////////////////////////
|
|
106
|
+
const useBackend = create((set, get) => ({
|
|
107
|
+
user: {},
|
|
108
|
+
classfiles: { free: [], enterprise: [] },
|
|
109
|
+
api: api(set, get),
|
|
110
|
+
status: { inProgress: {} },
|
|
111
|
+
}));
|
|
112
|
+
export { useBackend };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { FirebaseApp } from 'firebase/app';
|
|
2
|
+
export type BackendName = 'production' | 'develop' | 'emulator';
|
|
3
|
+
export declare const backendName: BackendName;
|
|
4
|
+
export declare const firebaseApp: FirebaseApp;
|
|
5
|
+
export declare const firebaseFunctionsUrl: string;
|
|
6
|
+
export declare const apiBaseUrl: string;
|
|
7
|
+
export declare const googleClientId: string;
|
|
8
|
+
export declare const googleRedirectOrigins: string[];
|
|
9
|
+
export declare const firebaseAuth: import("@firebase/auth").Auth;
|
|
10
|
+
export declare const firebaseStorage: import("@firebase/storage").FirebaseStorage;
|
|
11
|
+
export declare const firebaseDb: import("@firebase/firestore").Firestore;
|
|
12
|
+
export declare const firebaseFunctions: import("@firebase/functions").Functions;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { initializeApp, getApps, getApp } from 'firebase/app';
|
|
2
|
+
import { connectAuthEmulator, getAuth } from 'firebase/auth';
|
|
3
|
+
import { connectFirestoreEmulator, getFirestore } from 'firebase/firestore';
|
|
4
|
+
import { getStorage } from 'firebase/storage';
|
|
5
|
+
import { getAnalytics } from 'firebase/analytics';
|
|
6
|
+
import { getFunctions } from 'firebase/functions';
|
|
7
|
+
const LIVE = 'develop';
|
|
8
|
+
const chooseBackend = () => {
|
|
9
|
+
if (typeof window === 'undefined')
|
|
10
|
+
return LIVE;
|
|
11
|
+
try {
|
|
12
|
+
const chosen = window.localStorage.getItem('classcad.backend');
|
|
13
|
+
if (chosen === 'production' || chosen === 'develop' || chosen === 'emulator')
|
|
14
|
+
return chosen;
|
|
15
|
+
}
|
|
16
|
+
catch (error) { }
|
|
17
|
+
const host = window.location.hostname;
|
|
18
|
+
return ['localhost', '127.0.0.1', '[::1]'].includes(host) || host.startsWith('staging01.') ? 'develop' : LIVE;
|
|
19
|
+
};
|
|
20
|
+
export const backendName = chooseBackend();
|
|
21
|
+
const production = JSON.parse(atob('eyJhcGlLZXkiOiJBSXphU3lDektoVk9YSk93THRtSzlTakNvRUVJa1o2TjdpbFNwOUUiLCJhdXRoRG9tYWluIjoiYnVlcmxpLmZpcmViYXNlYXBwLmNvbSIsImRhdGFiYXNlVVJMIjoiaHR0cHM6Ly9idWVybGkuZmlyZWJhc2Vpby5jb20iLCJwcm9qZWN0SWQiOiJidWVybGkiLCJzdG9yYWdlQnVja2V0IjoiYnVlcmxpLmFwcHNwb3QuY29tIiwibWVzc2FnaW5nU2VuZGVySWQiOiI3NDM5NzkxMDM3NTIiLCJhcHBJZCI6IjE6NzQzOTc5MTAzNzUyOndlYjpmN2ZhMmVlZTBiYzZmZjhlYmM0M2JkIiwibWVhc3VyZW1lbnRJZCI6IkctV1ZOQzdKTFdYNCJ9'));
|
|
22
|
+
const develop = {
|
|
23
|
+
apiKey: 'AIzaSyA3emzV1j-4WrUduiQ9JJuRGhrR1MNWiqY',
|
|
24
|
+
authDomain: 'classcad-app.firebaseapp.com',
|
|
25
|
+
projectId: 'classcad-app',
|
|
26
|
+
storageBucket: 'classcad-app.firebasestorage.app',
|
|
27
|
+
messagingSenderId: '919811296885',
|
|
28
|
+
appId: '1:919811296885:web:629ab4ee369208314eb23c',
|
|
29
|
+
};
|
|
30
|
+
const emulator = { ...develop, apiKey: 'emulator', projectId: 'demo-classcad', authDomain: 'localhost' };
|
|
31
|
+
const backends = {
|
|
32
|
+
production: { config: production, funcUrl: 'https://backend.buerli.io' },
|
|
33
|
+
develop: { config: develop, funcUrl: 'https://europe-west1-classcad-app.cloudfunctions.net' },
|
|
34
|
+
emulator: { config: emulator, funcUrl: 'http://127.0.0.1:5001/demo-classcad/europe-west1' },
|
|
35
|
+
};
|
|
36
|
+
// The OAuth clients behind Firebase's Google provider, and the origins that client lists
|
|
37
|
+
// under "Authorized redirect URIs" as "<origin>/". Only those can sign in by redirect
|
|
38
|
+
// (see googleRedirect.ts); every other origin keeps the popup.
|
|
39
|
+
let googleClient = '743979103752-br1vnls884ictnh5cpnrua23rjiaj5dh.apps.googleusercontent.com';
|
|
40
|
+
let googleRedirects = [
|
|
41
|
+
'https://classcad.ch',
|
|
42
|
+
'https://www.classcad.ch',
|
|
43
|
+
'https://staging01.classcad.ch',
|
|
44
|
+
'http://localhost:9090',
|
|
45
|
+
];
|
|
46
|
+
if (backendName !== 'production') {
|
|
47
|
+
googleClient = '';
|
|
48
|
+
googleRedirects = [];
|
|
49
|
+
}
|
|
50
|
+
const { config: firebaseConfig, funcUrl } = backends[backendName];
|
|
51
|
+
const FIREBASE_APP_NAME = '___buerli-backend___';
|
|
52
|
+
let rootApp;
|
|
53
|
+
let fresh = false;
|
|
54
|
+
if (getApps().some(app => app.name === FIREBASE_APP_NAME)) {
|
|
55
|
+
rootApp = getApp(FIREBASE_APP_NAME);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
rootApp = initializeApp(firebaseConfig, FIREBASE_APP_NAME);
|
|
59
|
+
fresh = true;
|
|
60
|
+
}
|
|
61
|
+
export const firebaseApp = rootApp;
|
|
62
|
+
export const firebaseFunctionsUrl = funcUrl;
|
|
63
|
+
// The backend's API; the function URL and the custom domain both serve it under /api/v1
|
|
64
|
+
export const apiBaseUrl = `${funcUrl}/api/v1`;
|
|
65
|
+
export const googleClientId = googleClient;
|
|
66
|
+
export const googleRedirectOrigins = googleRedirects;
|
|
67
|
+
export const firebaseAuth = getAuth(firebaseApp);
|
|
68
|
+
export const firebaseStorage = getStorage(firebaseApp);
|
|
69
|
+
export const firebaseDb = getFirestore(firebaseApp);
|
|
70
|
+
export const firebaseFunctions = getFunctions(firebaseApp);
|
|
71
|
+
if (backendName === 'emulator' && fresh) {
|
|
72
|
+
connectAuthEmulator(firebaseAuth, 'http://127.0.0.1:9099', { disableWarnings: true });
|
|
73
|
+
connectFirestoreEmulator(firebaseDb, '127.0.0.1', 8080);
|
|
74
|
+
}
|
|
75
|
+
if (backendName === 'production' && typeof window !== 'undefined')
|
|
76
|
+
getAnalytics(firebaseApp);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ClassfileInfo, FileInfo } from '../types';
|
|
2
|
+
declare const firebaseAPI: {
|
|
3
|
+
firebaseApp: import("@firebase/app").FirebaseApp;
|
|
4
|
+
firebaseAuth: import("@firebase/auth").Auth;
|
|
5
|
+
firebaseStorage: import("@firebase/storage").FirebaseStorage;
|
|
6
|
+
firebaseFunctionsUrl: string;
|
|
7
|
+
fetchFile: (path: string, type?: "" | "arraybuffer" | "blob" | "document" | "json" | "text") => Promise<any>;
|
|
8
|
+
listAllFiles: (path: string) => Promise<FileInfo[]>;
|
|
9
|
+
getIdToken: (forceRefresh?: boolean) => Promise<string | undefined>;
|
|
10
|
+
signOut: () => Promise<boolean>;
|
|
11
|
+
signIn: (email: string, password: string) => Promise<boolean | undefined>;
|
|
12
|
+
createUser: (email: string, password: string) => Promise<boolean>;
|
|
13
|
+
sendVerifyEmail: () => Promise<boolean>;
|
|
14
|
+
validateEmail: (email: string) => Promise<boolean>;
|
|
15
|
+
resetPassword: (email: string) => Promise<boolean>;
|
|
16
|
+
/** @deprecated .ccapp packages (class files with the user's ccApp token): see ClassfileList. */
|
|
17
|
+
fetchCCApp: (classfile: ClassfileInfo) => Promise<ArrayBuffer>;
|
|
18
|
+
getRole: () => Promise<string | undefined | null>;
|
|
19
|
+
getAppKey: (options?: {
|
|
20
|
+
appType?: "native" | "wasm";
|
|
21
|
+
allowedOrigins?: string[];
|
|
22
|
+
}) => Promise<string>;
|
|
23
|
+
};
|
|
24
|
+
export { firebaseAPI };
|
|
25
|
+
export type Plan = {
|
|
26
|
+
name: string;
|
|
27
|
+
price: number | string;
|
|
28
|
+
pricingInterval: string;
|
|
29
|
+
__id: string;
|
|
30
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { ref as storageRef, getDownloadURL, listAll, getMetadata } from 'firebase/storage';
|
|
2
|
+
import { isValidEmail } from '../utils/isValidEmail';
|
|
3
|
+
import { apiBaseUrl, firebaseApp, firebaseAuth, firebaseStorage, firebaseFunctionsUrl } from './config';
|
|
4
|
+
import { signIn, signOut } from './signInOut';
|
|
5
|
+
const BASE_URL = apiBaseUrl;
|
|
6
|
+
const firebaseAPI = {
|
|
7
|
+
firebaseApp,
|
|
8
|
+
firebaseAuth,
|
|
9
|
+
firebaseStorage,
|
|
10
|
+
firebaseFunctionsUrl,
|
|
11
|
+
fetchFile: async (path, type = 'arraybuffer') => {
|
|
12
|
+
const url = await getDownloadURL(storageRef(firebaseStorage, path));
|
|
13
|
+
return await fetch(url).then(res => {
|
|
14
|
+
if (type === 'arraybuffer')
|
|
15
|
+
return res.arrayBuffer();
|
|
16
|
+
if (type === 'blob')
|
|
17
|
+
return res.blob();
|
|
18
|
+
if (type === 'json')
|
|
19
|
+
return res.json();
|
|
20
|
+
if (type === 'text')
|
|
21
|
+
return res.text();
|
|
22
|
+
return res;
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
listAllFiles: async (path) => {
|
|
26
|
+
const base64ToHex = (str) => {
|
|
27
|
+
const raw = atob(str);
|
|
28
|
+
let result = '';
|
|
29
|
+
for (let i = 0; i < raw.length; i++) {
|
|
30
|
+
const hex = raw.charCodeAt(i).toString(16);
|
|
31
|
+
result += hex.length === 2 ? hex : '0' + hex;
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
const modelsRef = storageRef(firebaseStorage, path);
|
|
36
|
+
const references = (await listAll(modelsRef)).items;
|
|
37
|
+
const files = [];
|
|
38
|
+
for (const refItem of references) {
|
|
39
|
+
const url = await getDownloadURL(refItem);
|
|
40
|
+
const meta = await getMetadata(refItem);
|
|
41
|
+
const md5Hash = meta.md5Hash ? base64ToHex(meta.md5Hash) : '';
|
|
42
|
+
files.push({ fullPath: refItem.fullPath, name: refItem.name, downloadUrl: url, meta: { md5Hash } });
|
|
43
|
+
}
|
|
44
|
+
return files;
|
|
45
|
+
},
|
|
46
|
+
getIdToken: async (forceRefresh) => {
|
|
47
|
+
if (!firebaseAuth.currentUser)
|
|
48
|
+
return undefined;
|
|
49
|
+
const { getIdToken } = await import('firebase/auth');
|
|
50
|
+
return getIdToken(firebaseAuth.currentUser, forceRefresh);
|
|
51
|
+
},
|
|
52
|
+
signOut: async () => {
|
|
53
|
+
return signOut();
|
|
54
|
+
},
|
|
55
|
+
signIn: async (email, password) => {
|
|
56
|
+
return signIn(email, password);
|
|
57
|
+
},
|
|
58
|
+
createUser: async (email, password) => {
|
|
59
|
+
try {
|
|
60
|
+
if (!isValidEmail(email))
|
|
61
|
+
throw new Error('Bad email!');
|
|
62
|
+
if (!password || password.length < 8)
|
|
63
|
+
throw new Error('Bad password!');
|
|
64
|
+
const isValid = await firebaseAPI.validateEmail(email);
|
|
65
|
+
if (!isValid)
|
|
66
|
+
throw new Error('Email address is not supported!');
|
|
67
|
+
const { createUserWithEmailAndPassword } = await import('firebase/auth');
|
|
68
|
+
await createUserWithEmailAndPassword(firebaseAuth, email, password);
|
|
69
|
+
firebaseAPI.sendVerifyEmail();
|
|
70
|
+
// firebaseAPI.signOut();
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
75
|
+
alert(message);
|
|
76
|
+
console.error({ type: 'error', title: 'Creating the user has failed!', message });
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
sendVerifyEmail: async () => {
|
|
81
|
+
try {
|
|
82
|
+
if (firebaseAuth.currentUser) {
|
|
83
|
+
const { sendEmailVerification } = await import('firebase/auth');
|
|
84
|
+
await sendEmailVerification(firebaseAuth.currentUser);
|
|
85
|
+
}
|
|
86
|
+
console.info({ type: 'info', title: 'Verification email has been sent!' });
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
91
|
+
console.error({ type: 'error', title: 'Sending the verification email has failed!', message });
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
validateEmail: async (email) => {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const url = `${BASE_URL}/validate/email/${email}`;
|
|
98
|
+
const xhr = new XMLHttpRequest();
|
|
99
|
+
xhr.responseType = 'json';
|
|
100
|
+
xhr.onload = () => {
|
|
101
|
+
resolve(xhr.response.valid);
|
|
102
|
+
};
|
|
103
|
+
xhr.onerror = () => {
|
|
104
|
+
reject(new Error('Network request failed'));
|
|
105
|
+
};
|
|
106
|
+
xhr.open('GET', url);
|
|
107
|
+
xhr.send();
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
resetPassword: async (email) => {
|
|
111
|
+
try {
|
|
112
|
+
if (!isValidEmail(email))
|
|
113
|
+
throw new Error('Bad email!');
|
|
114
|
+
const isValid = await firebaseAPI.validateEmail(email);
|
|
115
|
+
if (!isValid)
|
|
116
|
+
throw new Error('Not a valid email address!');
|
|
117
|
+
const { sendPasswordResetEmail } = await import('firebase/auth');
|
|
118
|
+
await sendPasswordResetEmail(firebaseAuth, email);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
123
|
+
console.error({ type: 'error', title: `Password reset failed! ${message}`, message });
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
/** @deprecated .ccapp packages (class files with the user's ccApp token): see ClassfileList. */
|
|
128
|
+
fetchCCApp: async (classfile) => {
|
|
129
|
+
return new Promise(async (resolve, reject) => {
|
|
130
|
+
try {
|
|
131
|
+
const url = `${BASE_URL}/user/ccapp`;
|
|
132
|
+
const xhr = new XMLHttpRequest();
|
|
133
|
+
xhr.responseType = 'arraybuffer';
|
|
134
|
+
xhr.onload = () => {
|
|
135
|
+
resolve(xhr.response);
|
|
136
|
+
};
|
|
137
|
+
xhr.onerror = e => {
|
|
138
|
+
reject(e);
|
|
139
|
+
};
|
|
140
|
+
xhr.open('POST', url);
|
|
141
|
+
xhr.setRequestHeader('Content-Type', 'application/json');
|
|
142
|
+
xhr.setRequestHeader('Authorization', 'Bearer ' + (await firebaseAPI.getIdToken(true)));
|
|
143
|
+
xhr.send(JSON.stringify({ fileUrl: classfile.fullPath }));
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
reject(error);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
getRole: async () => {
|
|
151
|
+
if (!firebaseAuth.currentUser)
|
|
152
|
+
return null;
|
|
153
|
+
const { getIdTokenResult } = await import('firebase/auth');
|
|
154
|
+
const user = await getIdTokenResult(firebaseAuth.currentUser);
|
|
155
|
+
return typeof user.claims?.role === 'string' ? user.claims.role : null;
|
|
156
|
+
},
|
|
157
|
+
getAppKey: async (options) => {
|
|
158
|
+
return new Promise(async (resolve, reject) => {
|
|
159
|
+
try {
|
|
160
|
+
const { appType = 'native', allowedOrigins = [] } = options || {};
|
|
161
|
+
const aO = allowedOrigins.join(',');
|
|
162
|
+
const url = `${BASE_URL}/appkey?appType=${appType}&allowedOrigins=${aO}`;
|
|
163
|
+
const xhr = new XMLHttpRequest();
|
|
164
|
+
xhr.responseType = 'text';
|
|
165
|
+
xhr.onload = () => {
|
|
166
|
+
resolve(xhr.response);
|
|
167
|
+
};
|
|
168
|
+
xhr.onerror = e => {
|
|
169
|
+
reject(e);
|
|
170
|
+
};
|
|
171
|
+
xhr.open('GET', url);
|
|
172
|
+
xhr.setRequestHeader('Content-Type', 'application/json');
|
|
173
|
+
xhr.setRequestHeader('Authorization', 'Bearer ' + (await firebaseAPI.getIdToken(true)));
|
|
174
|
+
xhr.send();
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
reject(error);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
export { firebaseAPI };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the Google sign-in should leave the page instead of opening a popup:
|
|
3
|
+
* on phones and tablets, where this origin is registered for it.
|
|
4
|
+
*/
|
|
5
|
+
export declare const shouldRedirectToGoogle: () => boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Sends the page to Google. The reader comes back to "<origin>/", where completeGoogleRedirect
|
|
8
|
+
* signs them in and returns them to the page they started on.
|
|
9
|
+
*/
|
|
10
|
+
export declare const redirectToGoogle: () => Promise<void>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { GoogleAuthProvider, signInWithCredential } from 'firebase/auth';
|
|
2
|
+
import { firebaseAuth, googleClientId, googleRedirectOrigins } from './config';
|
|
3
|
+
// Google sign-in without Firebase's hosted handler page.
|
|
4
|
+
//
|
|
5
|
+
// signInWithPopup sends the reader through <authDomain>/__/auth/handler, which keeps its state in
|
|
6
|
+
// sessionStorage while they are at Google. On phones, in-app browsers above all, that state is
|
|
7
|
+
// often gone on the way back: "Unable to process request due to missing initial state".
|
|
8
|
+
//
|
|
9
|
+
// Here the page itself goes to Google, and Google comes straight back to "<origin>/" with an ID
|
|
10
|
+
// token in the URL fragment, which Firebase takes as a credential. No window is opened, and
|
|
11
|
+
// nothing has to survive in another site's storage.
|
|
12
|
+
const PENDING = 'buerli-google-signin';
|
|
13
|
+
// How long the reader may spend at Google, in seconds
|
|
14
|
+
const MAX_AGE = 10 * 60;
|
|
15
|
+
const hex = (bytes) => Array.prototype.map.call(bytes, (byte) => ('0' + byte.toString(16)).slice(-2)).join('');
|
|
16
|
+
const sha256 = async (text) => hex(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))));
|
|
17
|
+
// Kept in localStorage and in a cookie: whichever is still there on the way back is enough.
|
|
18
|
+
const save = (pending) => {
|
|
19
|
+
const value = JSON.stringify(pending);
|
|
20
|
+
try {
|
|
21
|
+
window.localStorage.setItem(PENDING, value);
|
|
22
|
+
}
|
|
23
|
+
catch (error) { }
|
|
24
|
+
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
|
|
25
|
+
document.cookie = `${PENDING}=${encodeURIComponent(value)}; Max-Age=${MAX_AGE}; Path=/; SameSite=Lax${secure}`;
|
|
26
|
+
};
|
|
27
|
+
const load = () => {
|
|
28
|
+
let value = null;
|
|
29
|
+
try {
|
|
30
|
+
value = window.localStorage.getItem(PENDING);
|
|
31
|
+
}
|
|
32
|
+
catch (error) { }
|
|
33
|
+
if (!value) {
|
|
34
|
+
const cookie = document.cookie.split('; ').filter(entry => entry.indexOf(`${PENDING}=`) === 0)[0];
|
|
35
|
+
if (cookie)
|
|
36
|
+
value = decodeURIComponent(cookie.slice(PENDING.length + 1));
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const pending = value ? JSON.parse(value) : null;
|
|
40
|
+
return pending && typeof pending.raw === 'string' && typeof pending.returnTo === 'string' ? pending : null;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const clear = () => {
|
|
47
|
+
try {
|
|
48
|
+
window.localStorage.removeItem(PENDING);
|
|
49
|
+
}
|
|
50
|
+
catch (error) { }
|
|
51
|
+
document.cookie = `${PENDING}=; Max-Age=0; Path=/; SameSite=Lax`;
|
|
52
|
+
};
|
|
53
|
+
// The nonce Google wrote into the ID token (the token itself is verified by Firebase)
|
|
54
|
+
const nonceOf = (idToken) => {
|
|
55
|
+
try {
|
|
56
|
+
const payload = idToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
57
|
+
return JSON.parse(atob(payload)).nonce;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
// A path on this site, never another host
|
|
64
|
+
const isLocalPath = (path) => path.charAt(0) === '/' && path.charAt(1) !== '/' && path.charAt(1) !== '\\';
|
|
65
|
+
const isMobileBrowser = () => {
|
|
66
|
+
const ua = window.navigator.userAgent;
|
|
67
|
+
// An iPad reports a Mac; its touch points give it away
|
|
68
|
+
return /Android|iPhone|iPad|iPod/i.test(ua) || (/Macintosh/.test(ua) && window.navigator.maxTouchPoints > 1);
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Whether the Google sign-in should leave the page instead of opening a popup:
|
|
72
|
+
* on phones and tablets, where this origin is registered for it.
|
|
73
|
+
*/
|
|
74
|
+
export const shouldRedirectToGoogle = () => typeof window !== 'undefined' &&
|
|
75
|
+
Boolean(googleClientId) &&
|
|
76
|
+
googleRedirectOrigins.indexOf(window.location.origin) >= 0 &&
|
|
77
|
+
Boolean(window.crypto && window.crypto.subtle) &&
|
|
78
|
+
isMobileBrowser();
|
|
79
|
+
/**
|
|
80
|
+
* Sends the page to Google. The reader comes back to "<origin>/", where completeGoogleRedirect
|
|
81
|
+
* signs them in and returns them to the page they started on.
|
|
82
|
+
*/
|
|
83
|
+
export const redirectToGoogle = async () => {
|
|
84
|
+
const random = new Uint8Array(32);
|
|
85
|
+
window.crypto.getRandomValues(random);
|
|
86
|
+
const raw = hex(random);
|
|
87
|
+
// Google gets the hash, as nonce and as state; the value itself never leaves this browser
|
|
88
|
+
const nonce = await sha256(raw);
|
|
89
|
+
save({ raw, returnTo: window.location.pathname + window.location.search, at: Date.now() });
|
|
90
|
+
const params = new URLSearchParams([
|
|
91
|
+
['client_id', googleClientId],
|
|
92
|
+
['redirect_uri', `${window.location.origin}/`],
|
|
93
|
+
['response_type', 'id_token'],
|
|
94
|
+
['scope', 'openid email profile'],
|
|
95
|
+
['nonce', nonce],
|
|
96
|
+
['state', nonce],
|
|
97
|
+
['prompt', 'select_account'],
|
|
98
|
+
]);
|
|
99
|
+
window.location.assign(`https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`);
|
|
100
|
+
};
|
|
101
|
+
const completeGoogleRedirect = async () => {
|
|
102
|
+
const answer = new URLSearchParams(window.location.hash.slice(1));
|
|
103
|
+
const idToken = answer.get('id_token');
|
|
104
|
+
const state = answer.get('state');
|
|
105
|
+
const refused = answer.get('error');
|
|
106
|
+
if (!state || (!idToken && !refused))
|
|
107
|
+
return;
|
|
108
|
+
const pending = load();
|
|
109
|
+
// Some other fragment is left alone, but a token never stays in the address bar
|
|
110
|
+
if (!pending && !idToken)
|
|
111
|
+
return;
|
|
112
|
+
window.history.replaceState(window.history.state, '', window.location.pathname + window.location.search);
|
|
113
|
+
clear();
|
|
114
|
+
try {
|
|
115
|
+
if (!pending || Date.now() - pending.at > MAX_AGE * 1000) {
|
|
116
|
+
throw new Error('The sign-in was not started in this browser, or it took too long.');
|
|
117
|
+
}
|
|
118
|
+
const expected = await sha256(pending.raw);
|
|
119
|
+
if (state !== expected)
|
|
120
|
+
throw new Error('The answer does not belong to this sign-in.');
|
|
121
|
+
if (idToken) {
|
|
122
|
+
if (nonceOf(idToken) !== expected)
|
|
123
|
+
throw new Error('The answer does not belong to this sign-in.');
|
|
124
|
+
await signInWithCredential(firebaseAuth, GoogleAuthProvider.credential(idToken));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
129
|
+
console.error({ type: 'error', title: 'Sign in failed!', message });
|
|
130
|
+
alert(`Google sign-in could not be completed. ${message} Please try again, or sign in with your email address.`);
|
|
131
|
+
}
|
|
132
|
+
const here = window.location.pathname + window.location.search;
|
|
133
|
+
if (pending && isLocalPath(pending.returnTo) && pending.returnTo !== here) {
|
|
134
|
+
window.location.replace(pending.returnTo);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
if (typeof window !== 'undefined') {
|
|
138
|
+
completeGoogleRedirect().catch(error => console.error(error));
|
|
139
|
+
}
|