Feat/cloud integration (#569)

* Chore/unit test (#538)

* chore: add unit test

* chore: add github action for unit test

* feat: init firebase

* chore: add development secrets

* fix: rename auth -> data-services

* feat: update blocksuite 0.3.0-alpha.4 (#543)

* feat: add requests

* feat: optimize  swr cache

* feat: add Authorization

* feat: add confirm-invitation page

* feat: add account sdk api and proxy

* docs: update contributing (#550)

* docs: update contributing

* Update CONTRIBUTING.md

* Update CONTRIBUTING.md

Co-authored-by: ShortCipher5 <me@shortcipher.me>

* feat: update api

* feat: remove babelrc setting

* feat: add create workspace ui

* feat: choose workspaces

* feat: login modal

* feat: authorization api

* feat: login status

* fix: remove unused variables

* feat: login button

* fix: lint

* fix: workspace id

* fix: i18n type error

Co-authored-by: MingLiang Wang <mingliangwang0o0@gmail.com>
Co-authored-by: ShortCipher5 <me@shortcipher.me>
This commit is contained in:
zuomeng wang
2022-12-19 10:50:22 +08:00
committed by GitHub
parent 820d8d476a
commit 5870a6097a
91 changed files with 5391 additions and 524 deletions
+41
View File
@@ -0,0 +1,41 @@
import { initializeApp } from 'firebase/app';
import {
getAuth,
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
GoogleAuthProvider,
signInWithPopup,
} from 'firebase/auth';
import type { User } from 'firebase/auth';
/**
* firebaseConfig reference: https://firebase.google.com/docs/web/setup#add_firebase_to_your_app
*/
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,
});
export const firebaseAuth = getAuth(app);
const signUp = (email: string, password: string) => {
return createUserWithEmailAndPassword(firebaseAuth, email, password);
};
const signIn = (email: string, password: string) => {
return signInWithEmailAndPassword(firebaseAuth, email, password);
};
const googleAuthProvider = new GoogleAuthProvider();
export const signInWithGoogle = () => {
return signInWithPopup(firebaseAuth, googleAuthProvider);
};
export const onAuthStateChanged = (callback: (user: User | null) => void) => {
firebaseAuth.onAuthStateChanged(callback);
};
+3
View File
@@ -0,0 +1,3 @@
export { signInWithGoogle, onAuthStateChanged } from './auth';
export * from './request';
export * from './sdks';
@@ -0,0 +1,9 @@
export class ServiceError extends Error {
public message: string;
public code: string;
constructor(code: string, message: string) {
super(message);
this.message = message;
this.code = code;
}
}
@@ -0,0 +1,19 @@
import axios from 'axios';
import { setAuthorization } from './request';
import { handleResponseError } from './response';
declare module 'axios' {
interface AxiosRequestConfig {
/**
* If true, request will send with Authorization header.
*/
withAuthorization?: boolean;
}
}
export const request = axios.create({
withAuthorization: true,
});
request.interceptors.request.use(setAuthorization);
request.interceptors.response.use(handleResponseError);
@@ -0,0 +1,4 @@
export { request } from './axios';
export { ServiceError } from './ServiceError';
export { setToken, authorizationEvent } from './request';
export type { AccessTokenMessage } from './request';
@@ -0,0 +1,99 @@
import type { AxiosRequestConfig } from 'axios';
import { login } from '../../../sdks';
import { AuthorizationEvent } from './events';
import type { AccessTokenMessage } from './types';
const TOKEN_KEY = 'affine_token';
export const authorizationEvent = new AuthorizationEvent();
authorizationEvent.triggerChange(
parseAccessToken(getToken()?.accessToken || '')
);
interface Token {
accessToken: string;
refreshToken: string;
}
/**
* set null to clear token
*/
export function setToken(token: Token | null): void {
if (token === null) {
window.localStorage.removeItem(TOKEN_KEY);
authorizationEvent.triggerChange(null);
return;
}
window.localStorage.setItem(TOKEN_KEY, JSON.stringify(token));
authorizationEvent.triggerChange(parseAccessToken(token.accessToken));
}
function getToken(): { accessToken: string; refreshToken: string } | null {
try {
return JSON.parse(window.localStorage.getItem(TOKEN_KEY) || '');
} catch (error) {
return null;
}
}
function parseAccessToken(token: string): AccessTokenMessage | null {
try {
const message: AccessTokenMessage = JSON.parse(
window.atob(token.split('.')[1])
);
return message;
} catch (error) {
return null;
}
}
function isAccessTokenExpired(token: string) {
const message = parseAccessToken(token);
if (!message) {
return true;
}
return Date.now() - message.create_at > message.exp;
}
async function isLoggedIn(): Promise<boolean> {
const token = getToken();
if (!token) {
return false;
}
return isAccessTokenExpired(token.accessToken);
}
let refreshingToken: ReturnType<typeof login> | undefined;
export async function setAuthorization(config: AxiosRequestConfig<unknown>) {
if (!config.headers) {
config.headers = {};
}
if (config.withAuthorization) {
let token = getToken();
if (!token) {
throw new Error('No authorization token.');
}
if (isAccessTokenExpired(token.accessToken)) {
if (!refreshingToken) {
refreshingToken = login({
type: 'Refresh',
token: token.refreshToken,
});
}
const newToken = await refreshingToken;
token = {
accessToken: newToken.token,
refreshToken: newToken.refresh,
};
setToken(token);
refreshingToken = undefined;
}
if (!config.headers) {
config.headers = {};
}
config.headers['Authorization'] = token.accessToken;
}
return config;
}
@@ -0,0 +1,28 @@
import type { AccessTokenMessage } from './types';
type Callback = (user: AccessTokenMessage | null) => void;
export class AuthorizationEvent {
private callbacks: Callback[] = [];
private lastState: AccessTokenMessage | null = null;
/**
* Callback will execute when call this function.
*/
onChange(callback: Callback) {
this.callbacks.push(callback);
callback(this.lastState);
}
triggerChange(user: AccessTokenMessage | null) {
this.lastState = user;
this.callbacks.forEach(callback => callback(user));
}
removeCallback(callback: Callback) {
const index = this.callbacks.indexOf(callback);
if (index > -1) {
this.callbacks.splice(index, 1);
}
}
}
@@ -0,0 +1,2 @@
export * from './authorization';
export type { AccessTokenMessage } from './types';
@@ -0,0 +1,8 @@
export interface AccessTokenMessage {
create_at: number;
exp: number;
email: string;
id: number;
name: string;
avatar_url: string;
}
@@ -0,0 +1,6 @@
export {
setAuthorization,
setToken,
authorizationEvent,
} from './authorization';
export type { AccessTokenMessage } from './authorization';
@@ -0,0 +1,15 @@
import type { AxiosResponse } from 'axios';
import { ServiceError } from '../ServiceError';
export function handleResponseError(response: AxiosResponse<any, any>) {
const { data, status } = response;
if (status === 200) {
if (data.error) {
// TODO - common error handling
const error = new ServiceError(data.error.message, data.error.code);
throw error;
}
return response;
}
return response;
}
@@ -0,0 +1 @@
export { handleResponseError } from './handle-error';
@@ -0,0 +1,90 @@
import useSWR, { SWRConfiguration } from 'swr';
import { request, ServiceError } from '../request';
import type {
RequestAcceptInviting,
RequestInviteCollaborator,
RequestRemoveCollaborator,
ResponseInviteCollaborator,
} from './types';
const COLLABORATOR_INVITE_URL = '/api/account/invite';
const REMOVE_COLLABORATOR_URL = '/api/account/remove_collaborator';
async function doInviteCollaborator(
url: string,
req: RequestInviteCollaborator
) {
const { data } = await request.post<ResponseInviteCollaborator>(url, req);
return data;
}
export async function inviteCollaborator(req: RequestInviteCollaborator) {
return await doInviteCollaborator(COLLABORATOR_INVITE_URL, req);
}
async function doRemoveCollaborator(
url: string,
req: RequestRemoveCollaborator
) {
return request.post(url, req);
}
export async function removeCollaborator(req: RequestRemoveCollaborator) {
return await doRemoveCollaborator(REMOVE_COLLABORATOR_URL, req);
}
function doAcceptInviting(url: string, req: RequestAcceptInviting) {
return request.post(url, req);
}
export async function acceptInviting(req: RequestAcceptInviting) {
return await doAcceptInviting(COLLABORATOR_INVITE_URL, req);
}
export function useInviteCollaborator(
req: RequestInviteCollaborator,
config?: SWRConfiguration
) {
const { data, error } = useSWR<ResponseInviteCollaborator, ServiceError>(
[COLLABORATOR_INVITE_URL, req],
doInviteCollaborator,
config
);
return {
data,
isLoading: !error && !data,
isError: error,
};
}
export function useRemoveCollaborator(
req: RequestRemoveCollaborator,
config?: SWRConfiguration
) {
const { data, error } = useSWR<unknown, ServiceError>(
[REMOVE_COLLABORATOR_URL, req],
doRemoveCollaborator,
config
);
return {
data,
isLoading: !error && !data,
isError: error,
};
}
export function useAcceptInviting(
req: RequestAcceptInviting,
config?: SWRConfiguration
) {
const { data, error } = useSWR<unknown, ServiceError>(
[COLLABORATOR_INVITE_URL, req],
doAcceptInviting,
config
);
return {
data,
isLoading: !error && !data,
isError: error,
};
}
+4
View File
@@ -0,0 +1,4 @@
export * from './workspace';
export * from './workspace.hook';
export * from './user';
export * from './user.hook';
@@ -0,0 +1,33 @@
enum WorkspacePermission {
read = 'read',
write = 'write',
}
export type RequestInviteCollaborator = {
email: string;
workspace_id: string;
};
export type ResponseInviteCollaborator = {
inviting_code: string;
};
export type RequestRemoveCollaborator = {
user_id: string;
workspace_id: string;
};
export type ResponseRemoveCollaborator = {
workspace_id: string;
};
export type RequestAcceptInviting = {
inviting_code: string;
};
export type ResponseAcceptInviting = {
workspace_id: string;
name: string;
avatar_url: string;
permissions: WorkspacePermission;
};
@@ -0,0 +1,2 @@
export type CommonError = { error: { code: string; message: string } };
export type MayError = Partial<CommonError>;
@@ -0,0 +1,3 @@
export * from './common';
export * from './workspace';
export * from './account';
@@ -0,0 +1,32 @@
enum WorkspaceType {
'personal' = 'personal',
'team' = 'team',
}
export type Workspace = {
id: string;
name: string;
workspace_type: WorkspaceType;
avatar_url?: string;
public: boolean;
};
export type ResponseGetWorkspaces = {
create: Array<Workspace>;
read: Array<Workspace>;
write: Array<Workspace>;
};
export type RequestCreateWorkspace = {
name: string;
avatar_url?: string;
workspace_type: WorkspaceType;
};
export type RequestUpdateWorkspace = {
workspace_type?: WorkspaceType;
avatar_url?: string;
public?: boolean;
};
export type ResponseCreateWorkspace = any;
@@ -0,0 +1,45 @@
import useSWR from 'swr';
import type { SWRConfiguration } from 'swr';
import { login, getUserByEmail } from './user';
import type {
LoginParams,
LoginResponse,
GetUserByEmailParams,
User,
} from './user';
export const LOGIN_SWR_KEY = 'user.token';
export function useLogin(params: LoginParams, config?: SWRConfiguration) {
const { data, error, isValidating, isLoading, mutate } =
useSWR<LoginResponse>(
[LOGIN_SWR_KEY, params],
([_, params]) => login(params),
config
);
return {
loading: isLoading,
data,
error,
mutate,
};
}
export const GET_USER_BY_EMAIL_SWR_TOKEN = 'user.getUserByEmail';
export function useGetUserByEmail(
params: GetUserByEmailParams,
config?: SWRConfiguration
) {
const { data, error, isLoading, mutate } = useSWR<User | null>(
[GET_USER_BY_EMAIL_SWR_TOKEN, params],
([_, params]) => getUserByEmail(params),
config
);
return {
loading: isLoading,
data,
error,
mutate,
};
}
+60
View File
@@ -0,0 +1,60 @@
import { request } from '../request';
export interface ExchangeToken {
type: 'Google';
/**
* Token from firebase.
*/
token: string;
}
export interface RefreshToken {
type: 'Refresh';
token: string;
}
export type LoginParams = ExchangeToken | RefreshToken;
export interface LoginResponse {
/**
* JWT, expires in a very short time
*/
token: string;
/**
* Refresh token
*/
refresh: string;
}
export async function login(params: LoginParams): Promise<LoginResponse> {
const data = await request<LoginResponse>({
url: '/api/user/token',
method: 'POST',
data: params,
withAuthorization: false,
});
return data.data;
}
export interface GetUserByEmailParams {
email: string;
}
export interface User {
id: number;
name: string;
email: string;
avatar_url: string;
create_at: string;
}
export async function getUserByEmail(
params: GetUserByEmailParams
): Promise<User | null> {
const data = await request<User | null>({
url: '/api/user',
method: 'GET',
params,
});
return data.data;
}
@@ -0,0 +1,106 @@
import useSWR, { SWRConfiguration } from 'swr';
import type {
MayError,
RequestCreateWorkspace,
RequestUpdateWorkspace,
ResponseCreateWorkspace,
ResponseGetWorkspaces,
} from './types';
import { request, ServiceError } from '../request';
const WORKSPACE_URL = '/api/workspace';
async function doGetWorkSpaces(url: string) {
const { data } = await request.get<ResponseGetWorkspaces>(url);
return data;
}
export async function getWorkSpaces() {
return await doGetWorkSpaces(WORKSPACE_URL);
}
async function doCreateWorkspace(url: string, req: RequestCreateWorkspace) {
const { data } = await request.put<ResponseCreateWorkspace>(url, req);
return data;
}
export async function createWorkspace(req: RequestCreateWorkspace) {
doCreateWorkspace(WORKSPACE_URL, req);
}
async function doUpdateWorkspace(url: string, req: RequestUpdateWorkspace) {
const { data } = await request.post<MayError>(url, req);
return data;
}
export function updateWorkspace(id: string, req: RequestUpdateWorkspace) {
return doUpdateWorkspace(`${WORKSPACE_URL}${id}`, req);
}
async function doDeleteWorkspace(url: string) {
const { data } = await request.delete<MayError>(url);
return data;
}
export function deleteWorkspace(id: string) {
return doDeleteWorkspace(`${WORKSPACE_URL}${id}`);
}
export function useGetWorkspaces(config?: SWRConfiguration) {
const { data, error } = useSWR<ResponseGetWorkspaces, ServiceError, string>(
WORKSPACE_URL,
doGetWorkSpaces,
config
);
return {
data,
isLoading: !data,
isError: data && !error,
};
}
export function useCreateWorkspace(
req: RequestCreateWorkspace,
config?: SWRConfiguration
) {
const { data, error } = useSWR<ResponseGetWorkspaces, ServiceError>(
[WORKSPACE_URL, req],
doCreateWorkspace,
config
);
return {
data,
isLoading: !error && !data,
isError: error,
};
}
export function useUpdateWorkspace(
id: string,
req: RequestCreateWorkspace,
config?: SWRConfiguration
) {
const { data, error } = useSWR<MayError, ServiceError>(
[`${WORKSPACE_URL}${id}`, req],
doUpdateWorkspace,
config
);
return {
data,
isLoading: !error && !data,
isError: error,
};
}
export function useDeleteWorkspace(id: string, config?: SWRConfiguration) {
const { data, error } = useSWR<MayError, ServiceError>(
`${WORKSPACE_URL}${id}`,
doDeleteWorkspace,
config
);
return {
data,
isLoading: !error && !data,
isError: error,
};
}
@@ -0,0 +1,72 @@
import useSWR from 'swr';
import type { SWRConfiguration } from 'swr';
import {
getWorkspaceDetail,
updateWorkspace,
deleteWorkspace,
inviteMember,
Workspace,
} from './workspace';
import {
GetWorkspaceDetailParams,
WorkspaceDetail,
UpdateWorkspaceParams,
DeleteWorkspaceParams,
InviteMemberParams,
getWorkspaces,
} from './workspace';
export const GET_WORKSPACE_DETAIL_SWR_TOKEN = 'workspace.getWorkspaceDetail';
export function useGetWorkspaceDetail(
params: GetWorkspaceDetailParams,
config?: SWRConfiguration
) {
const { data, error, isLoading, mutate } = useSWR<WorkspaceDetail | null>(
[GET_WORKSPACE_DETAIL_SWR_TOKEN, params],
([_, params]) => getWorkspaceDetail(params),
config
);
return {
data,
error,
loading: isLoading,
mutate,
};
}
export const GET_WORKSPACES_SWR_TOKEN = 'workspace.getWorkspaces';
export function useGetWorkspaces(config?: SWRConfiguration) {
const { data, error, isLoading } = useSWR<Workspace[]>(
[GET_WORKSPACES_SWR_TOKEN],
() => getWorkspaces(),
config
);
return {
data,
error,
loading: isLoading,
};
}
export const UPDATE_WORKSPACE_SWR_TOKEN = 'workspace.updateWorkspace';
/**
* I don't think a hook needed for update workspace.
* If you figure out the scene, please implement this function.
*/
export function useUpdateWorkspace() {}
export const DELETE_WORKSPACE_SWR_TOKEN = 'workspace.deleteWorkspace';
/**
* I don't think a hook needed for delete workspace.
* If you figure out the scene, please implement this function.
*/
export function useDeleteWorkspace() {}
export const INVITE_MEMBER_SWR_TOKEN = 'workspace.inviteMember';
/**
* I don't think a hook needed for invite member.
* If you figure out the scene, please implement this function.
*/
export function useInviteMember() {}
@@ -0,0 +1,186 @@
import { request } from '../request';
import { User } from './user';
export interface GetWorkspaceDetailParams {
id: string;
}
export enum WorkspaceType {
Private = 0,
Normal = 1,
}
export enum PermissionType {
Read = 0,
Write = 1,
Admin = 2,
Owner = 3,
}
export interface Workspace {
id: string;
type: WorkspaceType;
public: boolean;
permission_type: PermissionType;
create_at: number;
}
export async function getWorkspaces(): Promise<Workspace[]> {
const data = await request({
url: '/api/workspace',
method: 'GET',
});
return data.data;
}
export interface WorkspaceDetail extends Workspace {
owner: User;
member_count: number;
}
export async function getWorkspaceDetail(
params: GetWorkspaceDetailParams
): Promise<WorkspaceDetail | null> {
const data = await request<WorkspaceDetail | null>({
url: `/api/workspace/${params.id}`,
method: 'PUT',
});
return data.data;
}
export interface Permission {
id: number;
type: PermissionType;
workspace_id: string;
accepted: boolean;
create_at: number;
}
export interface RegisteredUser extends User {
type: 'Registered';
}
export interface UnregisteredUser {
type: 'Unregistered';
email: string;
}
export interface Member extends Permission {
user: RegisteredUser | UnregisteredUser;
}
export interface GetWorkspaceMembersParams {
id: string;
}
export async function getWorkspaceMembers(
params: GetWorkspaceDetailParams
): Promise<Member[]> {
const data = await request<Member[]>({
url: `/api/workspace/${params.id}/permission`,
method: 'GET',
});
return data.data;
}
export interface CreateWorkspaceParams {
name: string;
avatar: string;
}
export async function createWorkspace(
params: CreateWorkspaceParams
): Promise<void> {
const data = await request({
url: '/api/workspace',
method: 'POST',
data: params,
});
return data.data;
}
export interface UpdateWorkspaceParams {
id: string;
public: boolean;
}
export async function updateWorkspace(
params: UpdateWorkspaceParams
): Promise<void> {
const data = await request({
url: `/api/workspace/${params.id}`,
method: 'POST',
data: {
public: params.public,
},
});
return data.data;
}
export interface DeleteWorkspaceParams {
id: string;
}
export async function deleteWorkspace(
params: DeleteWorkspaceParams
): Promise<void> {
const data = await request({
url: `/api/workspace/${params.id}`,
method: 'DELETE',
});
return data.data;
}
export interface InviteMemberParams {
id: string;
email: string;
}
/**
* Notice: Only support normal(contrast to private) workspace.
*/
export async function inviteMember(params: InviteMemberParams): Promise<void> {
const data = await request({
url: `/api/workspace/${params.id}/permission`,
method: 'POST',
data: {
email: params.email,
},
});
return data.data;
}
export interface RemoveMemberParams {
permissionId: number;
}
export async function removeMember(params: RemoveMemberParams): Promise<void> {
const data = await request({
url: `/api/permission/${params.permissionId}`,
method: 'DELETE',
});
return data.data;
}
export interface AcceptInvitingParams {
invitingCode: string;
}
export async function acceptInviting(
params: AcceptInvitingParams
): Promise<void> {
const data = await request({
url: `/api/invite/${params.invitingCode}`,
method: 'POST',
});
return data.data;
}