refactor: init package @affine/workspace (#1661)

This commit is contained in:
Himself65
2023-03-23 11:17:38 -05:00
committed by GitHub
parent 84d27e939d
commit 69721f2a61
44 changed files with 952 additions and 236 deletions

View File

@@ -0,0 +1,22 @@
{
"name": "@affine/workspace",
"private": true,
"exports": {
"./utils": "./src/utils.ts",
"./type": "./src/type.ts",
"./affine/*": "./src/affine/*.ts"
},
"dependencies": {
"@affine/component": "workspace:*",
"@affine/debug": "workspace:*",
"@affine/env": "workspace:*",
"@blocksuite/blocks": "0.5.0-20230323085636-3110abb",
"@blocksuite/store": "0.5.0-20230323085636-3110abb",
"firebase": "^9.18.0",
"jotai": "^2.0.3",
"js-base64": "^3.7.5",
"ky": "^0.33.3",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}

View File

@@ -0,0 +1,20 @@
import { getDefaultStore } from 'jotai';
import { describe, expect, test } from 'vitest';
import { currentAffineUserAtom } from '../atom';
describe('atom', () => {
test('currentAffineUserAtom', () => {
const store = getDefaultStore();
const mock = {
created_at: 0,
exp: 0,
email: '',
id: '',
name: '',
avatar_url: '',
};
store.set(currentAffineUserAtom, mock);
expect(store.get(currentAffineUserAtom)).toEqual(mock);
});
});

View File

@@ -0,0 +1,42 @@
/**
* @vitest-environment happy-dom
*/
import type { AccessTokenMessage } from '@affine/workspace/affine/login';
import {
getLoginStorage,
isExpired,
setLoginStorage,
STORAGE_KEY,
} from '@affine/workspace/affine/login';
import { describe, expect, test } from 'vitest';
describe('storage', () => {
test('should work', () => {
setLoginStorage({
token: '1',
refresh: '2',
});
const data = localStorage.getItem(STORAGE_KEY);
expect(data).toBe('{"token":"1","refresh":"2"}');
const login = getLoginStorage();
expect(login).toEqual({
token: '1',
refresh: '2',
});
});
});
describe('utils', () => {
test('isExpired', async () => {
const now = Math.floor(Date.now() / 1000);
expect(isExpired({ exp: now + 1 } as AccessTokenMessage)).toBeFalsy();
const promise = new Promise<void>(resolve => {
setTimeout(() => {
expect(isExpired({ exp: now + 1 } as AccessTokenMessage)).toBeTruthy();
resolve();
}, 2000);
});
expect(isExpired({ exp: now - 1 } as AccessTokenMessage)).toBeTruthy();
await promise;
});
});

View File

@@ -0,0 +1,4 @@
import type { AccessTokenMessage } from '@affine/workspace/affine/login';
import { atom } from 'jotai';
export const currentAffineUserAtom = atom<AccessTokenMessage | null>(null);

View File

@@ -0,0 +1,175 @@
import { DebugLogger } from '@affine/debug';
import { initializeApp } from 'firebase/app';
import type { AuthProvider } from 'firebase/auth';
import {
type Auth as FirebaseAuth,
connectAuthEmulator,
getAuth as getFirebaseAuth,
GithubAuthProvider,
GoogleAuthProvider,
signInWithPopup,
} from 'firebase/auth';
import { decode } from 'js-base64';
// Connect emulators based on env vars
const envConnectEmulators = process.env.REACT_APP_FIREBASE_EMULATORS === 'true';
export type AccessTokenMessage = {
created_at: number;
exp: number;
email: string;
id: string;
name: string;
avatar_url: string;
};
export type LoginParams = {
type: 'Google' | 'Refresh';
token: string;
};
export type LoginResponse = {
// access token, expires in a very short time
token: string;
// Refresh token
refresh: string;
};
const logger = new DebugLogger('token');
export const STORAGE_KEY = 'affine-login-v2';
export function parseIdToken(token: string): AccessTokenMessage {
return JSON.parse(decode(token.split('.')[1]));
}
export const isExpired = (token: AccessTokenMessage): boolean => {
const now = Math.floor(Date.now() / 1000);
return token.exp < now;
};
export const setLoginStorage = (login: LoginResponse) => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
token: login.token,
refresh: login.refresh,
})
);
};
export const clearLoginStorage = () => {
localStorage.removeItem(STORAGE_KEY);
};
export const getLoginStorage = (): LoginResponse | null => {
const login = localStorage.getItem(STORAGE_KEY);
if (login) {
try {
return JSON.parse(login);
} catch (error) {
logger.error('Failed to parse login', error);
}
}
return null;
};
export const enum SignMethod {
Google = 'Google',
GitHub = 'GitHub',
// Twitter = 'Twitter',
}
declare global {
// eslint-disable-next-line no-var
var firebaseAuthEmulatorStarted: boolean | undefined;
}
export function createAffineAuth() {
let _firebaseAuth: FirebaseAuth | null = null;
const getAuth = (): FirebaseAuth | null => {
try {
if (!_firebaseAuth) {
const app = initializeApp({
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId:
process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID,
});
_firebaseAuth = getFirebaseAuth(app);
}
if (envConnectEmulators && !globalThis.firebaseAuthEmulatorStarted) {
connectAuthEmulator(_firebaseAuth, 'http://localhost:9099', {
disableWarnings: true,
});
globalThis.firebaseAuthEmulatorStarted = true;
}
return _firebaseAuth;
} catch (error) {
logger.error('Failed to initialize firebase', error);
return null;
}
};
return {
generateToken: async (
method: SignMethod
): Promise<LoginResponse | null> => {
const auth = getAuth();
if (!auth) {
throw new Error('Failed to initialize firebase');
}
let provider: AuthProvider;
switch (method) {
case SignMethod.Google:
provider = new GoogleAuthProvider();
break;
case SignMethod.GitHub:
provider = new GithubAuthProvider();
break;
default:
throw new Error('Unsupported sign method');
}
try {
const response = await signInWithPopup(auth, provider);
const idToken = await response.user.getIdToken();
logger.debug(idToken);
return fetch('/api/user/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'Google',
token: idToken,
}),
}).then(r => r.json()) as Promise<LoginResponse>;
} catch (error) {
if (error instanceof Error && 'code' in error) {
if (error.code === 'auth/popup-closed-by-user') {
return null;
}
}
logger.error('Failed to sign in', error);
}
return null;
},
refreshToken: async (
loginResponse: LoginResponse
): Promise<LoginResponse | null> => {
return fetch('/api/user/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'Refresh',
token: loginResponse.refresh,
}),
}).then(r => r.json()) as Promise<LoginResponse>;
},
} as const;
}

View File

@@ -0,0 +1,74 @@
import type { Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
import type { FC } from 'react';
export const enum LoadPriority {
HIGH = 1,
MEDIUM = 2,
LOW = 3,
}
export const enum WorkspaceFlavour {
AFFINE = 'affine',
LOCAL = 'local',
}
export const settingPanel = {
General: 'general',
Collaboration: 'collaboration',
Publish: 'publish',
Export: 'export',
Sync: 'sync',
} as const;
export const settingPanelValues = [...Object.values(settingPanel)] as const;
export type SettingPanel = (typeof settingPanel)[keyof typeof settingPanel];
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface WorkspaceRegistry {}
export interface WorkspaceCRUD<Flavour extends keyof WorkspaceRegistry> {
create: (blockSuiteWorkspace: BlockSuiteWorkspace) => Promise<string>;
delete: (workspace: WorkspaceRegistry[Flavour]) => Promise<void>;
get: (workspaceId: string) => Promise<WorkspaceRegistry[Flavour] | null>;
// not supported yet
// update: (workspace: FlavourToWorkspace[Flavour]) => Promise<void>;
list: () => Promise<WorkspaceRegistry[Flavour][]>;
}
type UIBaseProps<Flavour extends keyof WorkspaceRegistry> = {
currentWorkspace: WorkspaceRegistry[Flavour];
};
type SettingProps<Flavour extends keyof WorkspaceRegistry> =
UIBaseProps<Flavour> & {
currentTab: SettingPanel;
onChangeTab: (tab: SettingPanel) => void;
onDeleteWorkspace: () => void;
onTransformWorkspace: <
From extends keyof WorkspaceRegistry,
To extends keyof WorkspaceRegistry
>(
from: From,
to: To,
workspace: WorkspaceRegistry[From]
) => void;
};
type PageDetailProps<Flavour extends keyof WorkspaceRegistry> =
UIBaseProps<Flavour> & {
currentPageId: string;
};
type PageListProps<Flavour extends keyof WorkspaceRegistry> = {
blockSuiteWorkspace: BlockSuiteWorkspace;
onOpenPage: (pageId: string, newTab?: boolean) => void;
};
type SideBarMenuProps<Flavour extends keyof WorkspaceRegistry> =
UIBaseProps<Flavour> & {
setSideBarOpen: (open: boolean) => void;
};
export interface WorkspaceUISchema<Flavour extends keyof WorkspaceRegistry> {
PageDetail: FC<PageDetailProps<Flavour>>;
PageList: FC<PageListProps<Flavour>>;
SettingsDetail: FC<SettingProps<Flavour>>;
}

View File

@@ -0,0 +1,24 @@
import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
import type { BlobOptionsGetter, Generator } from '@blocksuite/store';
import { Workspace } from '@blocksuite/store';
const hashMap = new Map<string, Workspace>();
export const createEmptyBlockSuiteWorkspace = (
id: string,
blobOptionsGetter?: BlobOptionsGetter,
idGenerator?: Generator
): Workspace => {
if (hashMap.has(id)) {
return hashMap.get(id) as Workspace;
}
const workspace = new Workspace({
id,
isSSR: typeof window === 'undefined',
blobOptionsGetter,
idGenerator,
})
.register(AffineSchemas)
.register(__unstableSchemas);
hashMap.set(id, workspace);
return workspace;
};

View File

@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["./src"]
}