Merge remote-tracking branch 'origin/canary' into beta

This commit is contained in:
LongYinan
2024-03-26 14:53:01 +08:00
56 changed files with 1710 additions and 521 deletions
+2 -2
View File
@@ -30,7 +30,7 @@
"lint:eslint:fix": "yarn lint:eslint --fix",
"lint:prettier": "prettier --ignore-unknown --cache --check .",
"lint:prettier:fix": "prettier --ignore-unknown --cache --write .",
"lint:ox": "oxlint --import-plugin --deny-warnings -D correctness -D nursery -D prefer-array-some -D no-useless-promise-resolve-reject -D perf -A no-undef -A consistent-type-exports -A default -A named -A ban-ts-comment -A export",
"lint:ox": "oxlint --import-plugin --deny-warnings -D correctness -D nursery -D prefer-array-some -D no-useless-promise-resolve-reject -D perf -A no-undef -A consistent-type-exports -A default -A named -A ban-ts-comment -A export -A no-unresolved -A no-default-export -A no-duplicates -A no-side-effects-in-initialization -A no-named-as-default -A getter-return",
"lint": "yarn lint:eslint && yarn lint:prettier",
"lint:fix": "yarn lint:eslint:fix && yarn lint:prettier:fix",
"test": "vitest --run",
@@ -97,7 +97,7 @@
"nanoid": "^5.0.6",
"nx": "^18.0.4",
"nyc": "^15.1.0",
"oxlint": "0.0.22",
"oxlint": "0.2.14",
"prettier": "^3.2.5",
"semver": "^7.6.0",
"serve": "^14.2.1",
@@ -6,6 +6,7 @@ import {
Controller,
Get,
Header,
HttpStatus,
Post,
Query,
Req,
@@ -13,11 +14,7 @@ import {
} from '@nestjs/common';
import type { Request, Response } from 'express';
import {
Config,
PaymentRequiredException,
URLHelper,
} from '../../fundamentals';
import { PaymentRequiredException, URLHelper } from '../../fundamentals';
import { UserService } from '../user';
import { validators } from '../utils/validators';
import { CurrentUser } from './current-user';
@@ -33,7 +30,6 @@ class SignInCredential {
@Controller('/api/auth')
export class AuthController {
constructor(
private readonly config: Config,
private readonly url: URLHelper,
private readonly auth: AuthService,
private readonly user: UserService,
@@ -64,7 +60,7 @@ export class AuthController {
);
await this.auth.setCookie(req, res, user);
res.send(user);
res.status(HttpStatus.OK).send(user);
} else {
// send email magic link
const user = await this.user.findUserByEmail(credential.email);
@@ -77,7 +73,7 @@ export class AuthController {
throw new Error('Failed to send sign-in email.');
}
res.send({
res.status(HttpStatus.OK).send({
email: credential.email,
});
}
@@ -162,22 +158,6 @@ export class AuthController {
return this.url.safeRedirect(res, redirectUri);
}
@Get('/authorize')
async authorize(
@CurrentUser() user: CurrentUser,
@Query('redirect_uri') redirect_uri?: string
) {
const session = await this.auth.createUserSession(
user,
undefined,
this.config.auth.accessToken.ttl
);
this.url.link(redirect_uri ?? '/open-app/redirect', {
token: session.sessionId,
});
}
@Public()
@Get('/session')
async currentSessionUser(@CurrentUser() user?: CurrentUser) {
@@ -5,7 +5,7 @@ import { UserModule } from '../user';
import { AuthController } from './controller';
import { AuthResolver } from './resolver';
import { AuthService } from './service';
import { TokenService } from './token';
import { TokenService, TokenType } from './token';
@Module({
imports: [FeatureModule, UserModule],
@@ -17,5 +17,5 @@ export class AuthModule {}
export * from './guard';
export { ClientTokenType } from './resolver';
export { AuthService };
export { AuthService, TokenService, TokenType };
export * from './current-user';
@@ -17,6 +17,7 @@ import {
import type { Request, Response } from 'express';
import { CloudThrottlerGuard, Config, Throttle } from '../../fundamentals';
import { UserService } from '../user';
import { UserType } from '../user/types';
import { validators } from '../utils/validators';
import { CurrentUser } from './current-user';
@@ -48,6 +49,7 @@ export class AuthResolver {
constructor(
private readonly config: Config,
private readonly auth: AuthService,
private readonly user: UserService,
private readonly token: TokenService
) {}
@@ -165,7 +167,7 @@ export class AuthResolver {
throw new ForbiddenException('Invalid token');
}
await this.auth.changePassword(user.email, newPassword);
await this.auth.changePassword(user.id, newPassword);
return user;
}
@@ -319,7 +321,7 @@ export class AuthResolver {
throw new ForbiddenException('Invalid token');
}
const hasRegistered = await this.auth.getUserByEmail(email);
const hasRegistered = await this.user.findUserByEmail(email);
if (hasRegistered) {
if (hasRegistered.id !== user.id) {
@@ -2,7 +2,6 @@ import {
BadRequestException,
Injectable,
NotAcceptableException,
NotFoundException,
OnApplicationBootstrap,
} from '@nestjs/common';
import type { User } from '@prisma/client';
@@ -10,30 +9,32 @@ import { PrismaClient } from '@prisma/client';
import type { CookieOptions, Request, Response } from 'express';
import { assign, omit } from 'lodash-es';
import {
Config,
CryptoHelper,
MailService,
SessionCache,
} from '../../fundamentals';
import { Config, CryptoHelper, MailService } from '../../fundamentals';
import { FeatureManagementService } from '../features/management';
import { UserService } from '../user/service';
import type { CurrentUser } from './current-user';
export function parseAuthUserSeqNum(value: any) {
let seq: number = 0;
switch (typeof value) {
case 'number': {
return value;
seq = value;
break;
}
case 'string': {
value = Number.parseInt(value);
return Number.isNaN(value) ? 0 : value;
const result = value.match(/^([\d{0, 10}])$/);
if (result?.[1]) {
seq = Number(result[1]);
}
break;
}
default: {
return 0;
seq = 0;
}
}
return Math.max(0, seq);
}
export function sessionUser(
@@ -57,7 +58,6 @@ export class AuthService implements OnApplicationBootstrap {
sameSite: 'lax',
httpOnly: true,
path: '/',
domain: this.config.host,
secure: this.config.https,
};
static readonly sessionCookieName = 'sid';
@@ -69,8 +69,7 @@ export class AuthService implements OnApplicationBootstrap {
private readonly mailer: MailService,
private readonly feature: FeatureManagementService,
private readonly user: UserService,
private readonly crypto: CryptoHelper,
private readonly cache: SessionCache
private readonly crypto: CryptoHelper
) {}
async onApplicationBootstrap() {
@@ -90,7 +89,7 @@ export class AuthService implements OnApplicationBootstrap {
email: string,
password: string
): Promise<CurrentUser> {
const user = await this.getUserByEmail(email);
const user = await this.user.findUserByEmail(email);
if (user) {
throw new BadRequestException('Email was taken');
@@ -111,12 +110,12 @@ export class AuthService implements OnApplicationBootstrap {
const user = await this.user.findUserWithHashedPasswordByEmail(email);
if (!user) {
throw new NotFoundException('User Not Found');
throw new NotAcceptableException('Invalid sign in credentials');
}
if (!user.password) {
throw new NotAcceptableException(
'User Password is not set. Should login throw email link.'
'User Password is not set. Should login through email link.'
);
}
@@ -126,28 +125,12 @@ export class AuthService implements OnApplicationBootstrap {
);
if (!passwordMatches) {
throw new NotAcceptableException('Incorrect Password');
throw new NotAcceptableException('Invalid sign in credentials');
}
return sessionUser(user);
}
async getUserWithCache(token: string, seq = 0) {
const cacheKey = `session:${token}:${seq}`;
let user = await this.cache.get<CurrentUser | null>(cacheKey);
if (user) {
return user;
}
user = await this.getUser(token, seq);
if (user) {
await this.cache.set(cacheKey, user);
}
return user;
}
async getUser(token: string, seq = 0): Promise<CurrentUser | null> {
const session = await this.getSession(token);
@@ -198,7 +181,16 @@ export class AuthService implements OnApplicationBootstrap {
// Session
// | { user: LimitedUser { email, avatarUrl }, expired: true }
// | { user: User, expired: false }
return users.map(sessionUser);
return session.userSessions
.map(userSession => {
// keep users in the same order as userSessions
const user = users.find(({ id }) => id === userSession.userId);
if (!user) {
return null;
}
return sessionUser(user);
})
.filter(Boolean) as CurrentUser[];
}
async signOut(token: string, seq = 0) {
@@ -319,12 +311,8 @@ export class AuthService implements OnApplicationBootstrap {
});
}
async getUserByEmail(email: string) {
return this.user.findUserByEmail(email);
}
async changePassword(email: string, newPassword: string): Promise<User> {
const user = await this.getUserByEmail(email);
async changePassword(id: string, newPassword: string): Promise<User> {
const user = await this.user.findUserById(id);
if (!user) {
throw new BadRequestException('Invalid email');
@@ -343,11 +331,7 @@ export class AuthService implements OnApplicationBootstrap {
}
async changeEmail(id: string, newEmail: string): Promise<User> {
const user = await this.db.user.findUnique({
where: {
id,
},
});
const user = await this.user.findUserById(id);
if (!user) {
throw new BadRequestException('Invalid email');
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaClient } from '@prisma/client';
import { CryptoHelper } from '../../fundamentals/helpers';
@@ -81,4 +82,15 @@ export class TokenService {
return valid ? record : null;
}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
cleanExpiredTokens() {
return this.db.verificationToken.deleteMany({
where: {
expiresAt: {
lte: new Date(),
},
},
});
}
}
@@ -13,12 +13,6 @@ declare global {
}
}
export enum ExternalAccount {
github = 'github',
google = 'google',
firebase = 'firebase',
}
export type ServerFlavor = 'allinone' | 'graphql' | 'sync';
export type AFFINE_ENV = 'dev' | 'beta' | 'production';
export type NODE_ENV = 'development' | 'test' | 'production';
@@ -6,7 +6,13 @@ import { PrismaService } from './service';
// only `PrismaClient` can be injected
const clientProvider: Provider = {
provide: PrismaClient,
useClass: PrismaService,
useFactory: () => {
if (PrismaService.INSTANCE) {
return PrismaService.INSTANCE;
}
return new PrismaService();
},
};
@Global()
@@ -19,6 +19,9 @@ export class PrismaService
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
if (!AFFiNE.node.test) {
await this.$disconnect();
PrismaService.INSTANCE = null;
}
}
}
@@ -40,7 +40,7 @@ export class OAuthController {
const provider = this.providerFactory.get(providerName);
if (!provider) {
throw new BadRequestException('Invalid provider');
throw new BadRequestException('Invalid OAuth provider');
}
const state = await this.oauth.saveOAuthState({
@@ -16,7 +16,7 @@ export function registerOAuthProvider(
@Injectable()
export class OAuthProviderFactory {
get providers() {
return PROVIDERS.keys();
return Array.from(PROVIDERS.keys());
}
get(name: OAuthProviderName): OAuthProvider | undefined {
@@ -0,0 +1,164 @@
import { HttpStatus, INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import request from 'supertest';
import { AuthModule, CurrentUser } from '../../src/core/auth';
import { AuthService } from '../../src/core/auth/service';
import { FeatureModule } from '../../src/core/features';
import { UserModule, UserService } from '../../src/core/user';
import { MailService } from '../../src/fundamentals';
import { createTestingApp, getSession, sessionCookie } from '../utils';
const test = ava as TestFn<{
auth: AuthService;
user: UserService;
u1: CurrentUser;
db: PrismaClient;
mailer: Sinon.SinonStubbedInstance<MailService>;
app: INestApplication;
}>;
test.beforeEach(async t => {
const { app } = await createTestingApp({
imports: [FeatureModule, UserModule, AuthModule],
tapModule: m => {
m.overrideProvider(MailService).useValue(
Sinon.createStubInstance(MailService)
);
},
});
t.context.auth = app.get(AuthService);
t.context.user = app.get(UserService);
t.context.db = app.get(PrismaClient);
t.context.mailer = app.get(MailService);
t.context.app = app;
t.context.u1 = await t.context.auth.signUp('u1', 'u1@affine.pro', '1');
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test('should be able to sign in with credential', async t => {
const { app, u1 } = t.context;
const res = await request(app.getHttpServer())
.post('/api/auth/sign-in')
.send({ email: u1.email, password: '1' })
.expect(200);
const session = await getSession(app, res);
t.is(session.user!.id, u1.id);
});
test('should be able to sign in with email', async t => {
const { app, u1, mailer } = t.context;
// @ts-expect-error mock
mailer.sendSignInMail.resolves({ rejected: [] });
const res = await request(app.getHttpServer())
.post('/api/auth/sign-in')
.send({ email: u1.email })
.expect(200);
t.is(res.body.email, u1.email);
t.true(mailer.sendSignInMail.calledOnce);
let [signInLink] = mailer.sendSignInMail.firstCall.args;
const url = new URL(signInLink);
signInLink = url.pathname + url.search;
const signInRes = await request(app.getHttpServer())
.get(signInLink)
.expect(302);
const session = await getSession(app, signInRes);
t.is(session.user!.id, u1.id);
});
test('should be able to sign up with email', async t => {
const { app, mailer } = t.context;
// @ts-expect-error mock
mailer.sendSignUpMail.resolves({ rejected: [] });
const res = await request(app.getHttpServer())
.post('/api/auth/sign-in')
.send({ email: 'u2@affine.pro' })
.expect(200);
t.is(res.body.email, 'u2@affine.pro');
t.true(mailer.sendSignUpMail.calledOnce);
let [signUpLink] = mailer.sendSignUpMail.firstCall.args;
const url = new URL(signUpLink);
signUpLink = url.pathname + url.search;
const signInRes = await request(app.getHttpServer())
.get(signUpLink)
.expect(302);
const session = await getSession(app, signInRes);
t.is(session.user!.email, 'u2@affine.pro');
});
test('should not be able to sign in if email is invalid', async t => {
const { app } = t.context;
const res = await request(app.getHttpServer())
.post('/api/auth/sign-in')
.send({ email: '' })
.expect(400);
t.is(res.body.message, 'Invalid email address');
});
test('should not be able to sign in if forbidden', async t => {
const { app, auth, u1, mailer } = t.context;
const canSignInStub = Sinon.stub(auth, 'canSignIn').resolves(false);
await request(app.getHttpServer())
.post('/api/auth/sign-in')
.send({ email: u1.email })
.expect(HttpStatus.PAYMENT_REQUIRED);
t.true(mailer.sendSignInMail.notCalled);
canSignInStub.restore();
});
test('should be able to sign out', async t => {
const { app, u1 } = t.context;
const signInRes = await request(app.getHttpServer())
.post('/api/auth/sign-in')
.send({ email: u1.email, password: '1' })
.expect(200);
const cookie = sessionCookie(signInRes.headers);
await request(app.getHttpServer())
.get('/api/auth/sign-out')
.set('cookie', cookie)
.expect(200);
const session = await getSession(app, signInRes);
t.falsy(session.user);
});
test('should not be able to sign out if not signed in', async t => {
const { app } = t.context;
await request(app.getHttpServer())
.get('/api/auth/sign-out')
.expect(HttpStatus.UNAUTHORIZED);
t.assert(true);
});
@@ -0,0 +1,131 @@
import { Controller, Get, HttpStatus, INestApplication } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import request from 'supertest';
import {
AuthGuard,
AuthModule,
CurrentUser,
Public,
} from '../../src/core/auth';
import { AuthService } from '../../src/core/auth/service';
import { createTestingApp } from '../utils';
@Controller('/')
class TestController {
@Public()
@Get('/public')
home(@CurrentUser() user?: CurrentUser) {
return { user };
}
@Get('/private')
private(@CurrentUser() user: CurrentUser) {
return { user };
}
}
const test = ava as TestFn<{
app: INestApplication;
auth: Sinon.SinonStubbedInstance<AuthService>;
}>;
test.beforeEach(async t => {
const { app } = await createTestingApp({
imports: [AuthModule],
providers: [
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],
controllers: [TestController],
tapModule: m => {
m.overrideProvider(AuthService).useValue(
Sinon.createStubInstance(AuthService)
);
},
});
t.context.auth = app.get(AuthService);
t.context.app = app;
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test('should be able to visit public api if not signed in', async t => {
const { app } = t.context;
const res = await request(app.getHttpServer()).get('/public').expect(200);
t.is(res.body.user, undefined);
});
test('should be able to visit public api if signed in', async t => {
const { app, auth } = t.context;
// @ts-expect-error mock
auth.getUser.resolves({ id: '1' });
const res = await request(app.getHttpServer())
.get('/public')
.set('Cookie', 'sid=1')
.expect(HttpStatus.OK);
t.is(res.body.user.id, '1');
});
test('should not be able to visit private api if not signed in', async t => {
const { app } = t.context;
await request(app.getHttpServer())
.get('/private')
.expect(HttpStatus.UNAUTHORIZED)
.expect({
statusCode: 401,
message: 'You are not signed in.',
error: 'Unauthorized',
});
t.assert(true);
});
test('should be able to visit private api if signed in', async t => {
const { app, auth } = t.context;
// @ts-expect-error mock
auth.getUser.resolves({ id: '1' });
const res = await request(app.getHttpServer())
.get('/private')
.set('Cookie', 'sid=1')
.expect(HttpStatus.OK);
t.is(res.body.user.id, '1');
});
test('should be able to parse session cookie', async t => {
const { app, auth } = t.context;
await request(app.getHttpServer())
.get('/public')
.set('cookie', 'sid=1')
.expect(200);
t.deepEqual(auth.getUser.firstCall.args, ['1', 0]);
});
test('should be able to parse bearer token', async t => {
const { app, auth } = t.context;
await request(app.getHttpServer())
.get('/public')
.auth('1', { type: 'bearer' })
.expect(200);
t.deepEqual(auth.getUser.firstCall.args, ['1', 0]);
});
@@ -0,0 +1,219 @@
import { TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { CurrentUser } from '../../src/core/auth';
import { AuthService, parseAuthUserSeqNum } from '../../src/core/auth/service';
import { FeatureModule } from '../../src/core/features';
import { UserModule, UserService } from '../../src/core/user';
import { createTestingModule } from '../utils';
const test = ava as TestFn<{
auth: AuthService;
user: UserService;
u1: CurrentUser;
db: PrismaClient;
m: TestingModule;
}>;
test.beforeEach(async t => {
const m = await createTestingModule({
imports: [FeatureModule, UserModule],
providers: [AuthService],
});
t.context.auth = m.get(AuthService);
t.context.user = m.get(UserService);
t.context.db = m.get(PrismaClient);
t.context.m = m;
t.context.u1 = await t.context.auth.signUp('u1', 'u1@affine.pro', '1');
});
test.afterEach.always(async t => {
await t.context.m.close();
});
test('should be able to parse auth user seq num', t => {
t.deepEqual(
[
'1',
'2',
3,
-3,
'-4',
'1.1',
'str',
'1111111111111111111111111111111111111111111',
].map(parseAuthUserSeqNum),
[1, 2, 3, 0, 0, 0, 0, 0]
);
});
test('should be able to sign up', async t => {
const { auth } = t.context;
const u2 = await auth.signUp('u2', 'u2@affine.pro', '1');
t.is(u2.email, 'u2@affine.pro');
const signedU2 = await auth.signIn(u2.email, '1');
t.is(u2.email, signedU2.email);
});
test('should throw if email duplicated', async t => {
const { auth } = t.context;
await t.throwsAsync(() => auth.signUp('u1', 'u1@affine.pro', '1'), {
message: 'Email was taken',
});
});
test('should be able to sign in', async t => {
const { auth } = t.context;
const signedInUser = await auth.signIn('u1@affine.pro', '1');
t.is(signedInUser.email, 'u1@affine.pro');
});
test('should throw if user not found', async t => {
const { auth } = t.context;
await t.throwsAsync(() => auth.signIn('u2@affine.pro', '1'), {
message: 'Invalid sign in credentials',
});
});
test('should throw if password not set', async t => {
const { user, auth } = t.context;
await user.createUser({
email: 'u2@affine.pro',
name: 'u2',
});
await t.throwsAsync(() => auth.signIn('u2@affine.pro', '1'), {
message: 'User Password is not set. Should login through email link.',
});
});
test('should throw if password not match', async t => {
const { auth } = t.context;
await t.throwsAsync(() => auth.signIn('u1@affine.pro', '2'), {
message: 'Invalid sign in credentials',
});
});
test('should be able to change password', async t => {
const { auth, u1 } = t.context;
let signedInU1 = await auth.signIn('u1@affine.pro', '1');
t.is(signedInU1.email, u1.email);
await auth.changePassword(u1.id, '2');
await t.throwsAsync(
() => auth.signIn('u1@affine.pro', '1' /* old password */),
{
message: 'Invalid sign in credentials',
}
);
signedInU1 = await auth.signIn('u1@affine.pro', '2');
t.is(signedInU1.email, u1.email);
});
test('should be able to change email', async t => {
const { auth, u1 } = t.context;
let signedInU1 = await auth.signIn('u1@affine.pro', '1');
t.is(signedInU1.email, u1.email);
await auth.changeEmail(u1.id, 'u2@affine.pro');
await t.throwsAsync(() => auth.signIn('u1@affine.pro' /* old email */, '1'), {
message: 'Invalid sign in credentials',
});
signedInU1 = await auth.signIn('u2@affine.pro', '1');
t.is(signedInU1.email, 'u2@affine.pro');
});
// Tests for Session
test('should be able to create user session', async t => {
const { auth, u1 } = t.context;
const session = await auth.createUserSession(u1);
t.is(session.userId, u1.id);
});
test('should be able to get user from session', async t => {
const { auth, u1 } = t.context;
const session = await auth.createUserSession(u1);
const user = await auth.getUser(session.sessionId);
t.not(user, null);
t.is(user!.id, u1.id);
});
test('should be able to sign out session', async t => {
const { auth, u1 } = t.context;
const session = await auth.createUserSession(u1);
const signedOutSession = await auth.signOut(session.sessionId);
t.is(signedOutSession, null);
});
// Tests for Multi-Accounts Session
test('should be able to sign in different user in a same session', async t => {
const { auth, u1 } = t.context;
const u2 = await auth.signUp('u2', 'u2@affine.pro', '1');
const session = await auth.createUserSession(u1);
await auth.createUserSession(u2, session.sessionId);
const [signedU1, signedU2] = await auth.getUserList(session.sessionId);
t.not(signedU1, null);
t.not(signedU2, null);
t.is(signedU1!.id, u1.id);
t.is(signedU2!.id, u2.id);
});
test('should be able to signout multi accounts session', async t => {
const { auth, u1 } = t.context;
const u2 = await auth.signUp('u2', 'u2@affine.pro', '1');
const session = await auth.createUserSession(u1);
await auth.createUserSession(u2, session.sessionId);
// sign out user at seq(0)
let signedOutSession = await auth.signOut(session.sessionId);
t.not(signedOutSession, null);
const signedU2 = await auth.getUser(session.sessionId, 0);
const noUser = await auth.getUser(session.sessionId, 1);
t.is(noUser, null);
t.not(signedU2, null);
t.is(signedU2!.id, u2.id);
// sign out user at seq(0)
signedOutSession = await auth.signOut(session.sessionId);
t.is(signedOutSession, null);
const noUser2 = await auth.getUser(session.sessionId, 0);
t.is(noUser2, null);
});
@@ -0,0 +1,93 @@
import { TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { TokenService, TokenType } from '../../src/core/auth';
import { createTestingModule } from '../utils';
const test = ava as TestFn<{
ts: TokenService;
m: TestingModule;
}>;
test.beforeEach(async t => {
const m = await createTestingModule({
providers: [TokenService],
});
t.context.ts = m.get(TokenService);
t.context.m = m;
});
test.afterEach.always(async t => {
await t.context.m.close();
});
test('should be able to create token', async t => {
const { ts } = t.context;
const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro');
t.truthy(
await ts.verifyToken(TokenType.SignIn, token, {
credential: 'user@affine.pro',
})
);
});
test('should fail the verification if the token is invalid', async t => {
const { ts } = t.context;
const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro');
// wrong type
t.falsy(
await ts.verifyToken(TokenType.ChangeEmail, token, {
credential: 'user@affine.pro',
})
);
// no credential
t.falsy(await ts.verifyToken(TokenType.SignIn, token));
// wrong credential
t.falsy(
await ts.verifyToken(TokenType.SignIn, token, {
credential: 'wrong@affine.pro',
})
);
});
test('should fail if the token expired', async t => {
const { ts } = t.context;
const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro');
await t.context.m.get(PrismaClient).verificationToken.updateMany({
data: {
expiresAt: new Date(Date.now() - 1000),
},
});
t.falsy(
await ts.verifyToken(TokenType.SignIn, token, {
credential: 'user@affine.pro',
})
);
});
test('should be able to verify only once', async t => {
const { ts } = t.context;
const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro');
t.truthy(
await ts.verifyToken(TokenType.SignIn, token, {
credential: 'user@affine.pro',
})
);
// will be invalid after the first time of verification
t.falsy(
await ts.verifyToken(TokenType.SignIn, token, {
credential: 'user@affine.pro',
})
);
});
@@ -0,0 +1,345 @@
import '../../src/plugins/config';
import { HttpStatus, INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import request from 'supertest';
import { AppModule } from '../../src/app.module';
import { CurrentUser } from '../../src/core/auth';
import { AuthService } from '../../src/core/auth/service';
import { UserService } from '../../src/core/user';
import { Config, ConfigModule } from '../../src/fundamentals/config';
import { GoogleOAuthProvider } from '../../src/plugins/oauth/providers/google';
import { OAuthService } from '../../src/plugins/oauth/service';
import { OAuthProviderName } from '../../src/plugins/oauth/types';
import { createTestingApp, getSession } from '../utils';
const test = ava as TestFn<{
auth: AuthService;
oauth: OAuthService;
user: UserService;
u1: CurrentUser;
db: PrismaClient;
app: INestApplication;
}>;
test.beforeEach(async t => {
const { app } = await createTestingApp({
imports: [
ConfigModule.forRoot({
plugins: {
oauth: {
providers: {
google: {
clientId: 'google-client-id',
clientSecret: 'google-client-secret',
},
},
},
},
}),
AppModule,
],
});
t.context.auth = app.get(AuthService);
t.context.oauth = app.get(OAuthService);
t.context.user = app.get(UserService);
t.context.db = app.get(PrismaClient);
t.context.app = app;
t.context.u1 = await t.context.auth.signUp('u1', 'u1@affine.pro', '1');
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test("should be able to redirect to oauth provider's login page", async t => {
const { app } = t.context;
const res = await request(app.getHttpServer())
.get('/oauth/login?provider=Google')
.expect(HttpStatus.FOUND);
const redirect = new URL(res.header.location);
t.is(redirect.origin, 'https://accounts.google.com');
t.is(redirect.pathname, '/o/oauth2/v2/auth');
t.is(redirect.searchParams.get('client_id'), 'google-client-id');
t.is(
redirect.searchParams.get('redirect_uri'),
app.get(Config).baseUrl + '/oauth/callback'
);
t.is(redirect.searchParams.get('response_type'), 'code');
t.is(redirect.searchParams.get('prompt'), 'select_account');
t.truthy(redirect.searchParams.get('state'));
});
test('should throw if provider is invalid', async t => {
const { app } = t.context;
await request(app.getHttpServer())
.get('/oauth/login?provider=Invalid')
.expect(HttpStatus.BAD_REQUEST)
.expect({
statusCode: 400,
message: 'Invalid OAuth provider',
error: 'Bad Request',
});
t.assert(true);
});
test('should be able to save oauth state', async t => {
const { oauth } = t.context;
const id = await oauth.saveOAuthState({
redirectUri: 'https://example.com',
provider: OAuthProviderName.Google,
});
const state = await oauth.getOAuthState(id);
t.truthy(state);
t.is(state!.provider, OAuthProviderName.Google);
t.is(state!.redirectUri, 'https://example.com');
});
test('should be able to get registered oauth providers', async t => {
const { oauth } = t.context;
const providers = oauth.availableOAuthProviders();
t.deepEqual(providers, [OAuthProviderName.Google]);
});
test('should throw if code is missing in callback uri', async t => {
const { app } = t.context;
await request(app.getHttpServer())
.get('/oauth/callback')
.expect(HttpStatus.BAD_REQUEST)
.expect({
statusCode: 400,
message: 'Missing query parameter `code`',
error: 'Bad Request',
});
t.assert(true);
});
test('should throw if state is missing in callback uri', async t => {
const { app } = t.context;
await request(app.getHttpServer())
.get('/oauth/callback?code=1')
.expect(HttpStatus.BAD_REQUEST)
.expect({
statusCode: 400,
message: 'Invalid callback state parameter',
error: 'Bad Request',
});
t.assert(true);
});
test('should throw if state is expired', async t => {
const { app } = t.context;
await request(app.getHttpServer())
.get('/oauth/callback?code=1&state=1')
.expect(HttpStatus.BAD_REQUEST)
.expect({
statusCode: 400,
message: 'OAuth state expired, please try again.',
error: 'Bad Request',
});
t.assert(true);
});
test('should throw if provider is missing in state', async t => {
const { app, oauth } = t.context;
// @ts-expect-error mock
Sinon.stub(oauth, 'getOAuthState').resolves({});
await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.expect(HttpStatus.BAD_REQUEST)
.expect({
statusCode: 400,
message: 'Missing callback state parameter `provider`',
error: 'Bad Request',
});
t.assert(true);
});
test('should throw if provider is invalid in callback uri', async t => {
const { app, oauth } = t.context;
// @ts-expect-error mock
Sinon.stub(oauth, 'getOAuthState').resolves({ provider: 'Invalid' });
await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.expect(HttpStatus.BAD_REQUEST)
.expect({
statusCode: 400,
message: 'Invalid provider',
error: 'Bad Request',
});
t.assert(true);
});
function mockOAuthProvider(app: INestApplication, email: string) {
const provider = app.get(GoogleOAuthProvider);
const oauth = app.get(OAuthService);
Sinon.stub(oauth, 'getOAuthState').resolves({
provider: OAuthProviderName.Google,
redirectUri: '/',
});
// @ts-expect-error mock
Sinon.stub(provider, 'getToken').resolves({ accessToken: '1' });
Sinon.stub(provider, 'getUser').resolves({
id: '1',
email,
avatarUrl: 'avatar',
});
}
test('should be able to sign up with oauth', async t => {
const { app, db } = t.context;
mockOAuthProvider(app, 'u2@affine.pro');
const res = await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.expect(HttpStatus.FOUND);
const session = await getSession(app, res);
t.truthy(session.user);
t.is(session.user!.email, 'u2@affine.pro');
const user = await db.user.findFirst({
select: {
email: true,
connectedAccounts: true,
},
where: {
email: 'u2@affine.pro',
},
});
t.truthy(user);
t.is(user!.email, 'u2@affine.pro');
t.is(user!.connectedAccounts[0].providerAccountId, '1');
});
test('should throw if account register in another way', async t => {
const { app, u1 } = t.context;
mockOAuthProvider(app, u1.email);
const res = await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.expect(HttpStatus.FOUND);
const link = new URL(res.headers.location);
t.is(link.pathname, '/signIn');
t.is(
link.searchParams.get('error'),
'The account with provided email is not register in the same way.'
);
});
test('should be able to fullfil user with oauth sign in', async t => {
const { app, user, db } = t.context;
const u3 = await user.createUser({
name: 'u3',
email: 'u3@affine.pro',
registered: false,
});
mockOAuthProvider(app, u3.email);
const res = await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.expect(HttpStatus.FOUND);
const session = await getSession(app, res);
t.truthy(session.user);
t.is(session.user!.email, u3.email);
const account = await db.connectedAccount.findFirst({
where: {
userId: u3.id,
},
});
t.truthy(account);
});
test('should throw if oauth account already connected', async t => {
const { app, db, u1, auth } = t.context;
await db.connectedAccount.create({
data: {
userId: u1.id,
provider: OAuthProviderName.Google,
providerAccountId: '1',
},
});
// @ts-expect-error mock
Sinon.stub(auth, 'getUser').resolves({ id: 'u2-id' });
mockOAuthProvider(app, 'u2@affine.pro');
const res = await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.set('cookie', 'sid=1')
.expect(HttpStatus.FOUND);
const link = new URL(res.headers.location);
t.is(link.pathname, '/signIn');
t.is(
link.searchParams.get('error'),
'The third-party account has already been connected to another user.'
);
});
test('should be able to connect oauth account', async t => {
const { app, u1, auth, db } = t.context;
// @ts-expect-error mock
Sinon.stub(auth, 'getUser').resolves({ id: u1.id });
mockOAuthProvider(app, u1.email);
await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.set('cookie', 'sid=1')
.expect(HttpStatus.FOUND);
const account = await db.connectedAccount.findFirst({
where: {
userId: u1.id,
},
});
t.truthy(account);
t.is(account!.userId, u1.id);
});
+27 -2
View File
@@ -1,11 +1,36 @@
import type { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import request, { type Response } from 'supertest';
import type { ClientTokenType } from '../../src/core/auth';
import type { ClientTokenType, CurrentUser } from '../../src/core/auth';
import type { UserType } from '../../src/core/user';
import { gql } from './common';
export function sessionCookie(headers: any) {
const cookie = headers['set-cookie']?.find((c: string) =>
c.startsWith('sid=')
);
if (!cookie) {
return null;
}
return cookie.split(';')[0];
}
export async function getSession(
app: INestApplication,
signInRes: Response
): Promise<{ user?: CurrentUser }> {
const cookie = sessionCookie(signInRes.headers);
const res = await request(app.getHttpServer())
.get('/api/auth/session')
.set('cookie', cookie)
.expect(200);
return res.body;
}
export async function signUp(
app: INestApplication,
name: string,
@@ -113,6 +113,7 @@ export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) {
cors: true,
bodyParser: true,
rawBody: true,
logger: ['warn'],
});
app.use(
@@ -9,6 +9,7 @@ import ava from 'ava';
import { AppModule } from '../src/app.module';
import { AuthService } from '../src/core/auth/service';
import { UserService } from '../src/core/user';
import { MailService } from '../src/fundamentals/mailer';
import {
acceptInviteById,
@@ -26,6 +27,7 @@ const test = ava as TestFn<{
client: PrismaClient;
auth: AuthService;
mail: MailService;
user: UserService;
}>;
test.beforeEach(async t => {
@@ -36,6 +38,7 @@ test.beforeEach(async t => {
t.context.client = app.get(PrismaClient);
t.context.auth = app.get(AuthService);
t.context.mail = app.get(MailService);
t.context.user = app.get(UserService);
});
test.afterEach.always(async t => {
@@ -96,16 +99,16 @@ test('should revoke a user', async t => {
});
test('should create user if not exist', async t => {
const { app, auth } = t.context;
const { app, user } = t.context;
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
await inviteUser(app, u1.token.token, workspace.id, 'u2@affine.pro', 'Admin');
const user = await auth.getUserByEmail('u2@affine.pro');
t.not(user, undefined, 'failed to create user');
t.is(user?.name, 'u2', 'failed to create user');
const u2 = await user.findUserByEmail('u2@affine.pro');
t.not(u2, undefined, 'failed to create user');
t.is(u2?.name, 'u2', 'failed to create user');
});
test('should invite a user by link', async t => {
+2
View File
@@ -3,6 +3,8 @@
"type": "module",
"private": true,
"exports": {
"./blocksuite": "./src/blocksuite/index.ts",
"./app-config-storage": "./src/app-config-storage.ts",
".": "./src/index.ts"
},
"dependencies": {
@@ -1,69 +1,18 @@
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { WorkspaceFlavour } from '@affine/env/workspace';
import { CollaborationIcon, SettingsIcon } from '@blocksuite/icons';
import type { WorkspaceMetadata } from '@toeverything/infra';
import { useCallback } from 'react';
import { Avatar } from '../../../ui/avatar';
import { Divider } from '../../../ui/divider';
import { Avatar, type AvatarProps } from '../../../ui/avatar';
import { Skeleton } from '../../../ui/skeleton';
import { Tooltip } from '../../../ui/tooltip';
import {
StyledCard,
StyledIconContainer,
StyledSettingLink,
StyledWorkspaceInfo,
StyledWorkspaceTitle,
StyledWorkspaceTitleArea,
StyledWorkspaceType,
StyledWorkspaceTypeEllipse,
StyledWorkspaceTypeText,
} from './styles';
import * as styles from './styles.css';
export interface WorkspaceTypeProps {
flavour: WorkspaceFlavour;
isOwner: boolean;
}
const WorkspaceType = ({ flavour, isOwner }: WorkspaceTypeProps) => {
const t = useAFFiNEI18N();
if (flavour === WorkspaceFlavour.LOCAL) {
return (
<StyledWorkspaceType>
<StyledWorkspaceTypeEllipse />
<StyledWorkspaceTypeText>{t['Local']()}</StyledWorkspaceTypeText>
</StyledWorkspaceType>
);
}
return isOwner ? (
<StyledWorkspaceType>
<StyledWorkspaceTypeEllipse cloud={true} />
<StyledWorkspaceTypeText>
{t['com.affine.brand.affineCloud']()}
</StyledWorkspaceTypeText>
</StyledWorkspaceType>
) : (
<StyledWorkspaceType>
<StyledWorkspaceTypeEllipse cloud={true} />
<StyledWorkspaceTypeText>
{t['com.affine.brand.affineCloud']()}
</StyledWorkspaceTypeText>
<Divider
orientation="vertical"
size="thinner"
style={{ margin: '0px 8px', height: '7px' }}
/>
<Tooltip content={t['com.affine.workspaceType.joined']()}>
<StyledIconContainer>
<CollaborationIcon />
</StyledIconContainer>
</Tooltip>
</StyledWorkspaceType>
);
};
export interface WorkspaceCardProps {
currentWorkspaceId?: string | null;
meta: WorkspaceMetadata;
@@ -77,7 +26,7 @@ export interface WorkspaceCardProps {
export const WorkspaceCardSkeleton = () => {
return (
<div>
<StyledCard data-testid="workspace-card">
<div className={styles.card} data-testid="workspace-card">
<Skeleton variant="circular" width={28} height={28} />
<Skeleton
variant="rectangular"
@@ -85,11 +34,14 @@ export const WorkspaceCardSkeleton = () => {
width={220}
style={{ marginLeft: '12px' }}
/>
</StyledCard>
</div>
</div>
);
};
const avatarImageProps = {
style: { borderRadius: 3, overflow: 'hidden' },
} satisfies AvatarProps['imageProps'];
export const WorkspaceCard = ({
onClick,
onSettingClick,
@@ -101,32 +53,38 @@ export const WorkspaceCard = ({
}: WorkspaceCardProps) => {
const displayName = name ?? UNTITLED_WORKSPACE_NAME;
return (
<StyledCard
<div
className={styles.card}
data-active={meta.id === currentWorkspaceId}
data-testid="workspace-card"
onClick={useCallback(() => {
onClick(meta);
}, [onClick, meta])}
active={meta.id === currentWorkspaceId}
>
<Avatar size={28} url={avatar} name={name} colorfulFallback />
<StyledWorkspaceInfo>
<StyledWorkspaceTitleArea style={{ display: 'flex' }}>
<StyledWorkspaceTitle>{displayName}</StyledWorkspaceTitle>
<Avatar
imageProps={avatarImageProps}
fallbackProps={avatarImageProps}
size={28}
url={avatar}
name={name}
colorfulFallback
/>
<div className={styles.workspaceInfo}>
<div className={styles.workspaceTitle}>{displayName}</div>
<StyledSettingLink
size="small"
className="setting-entry"
<div className={styles.actionButtons}>
{isOwner ? null : <CollaborationIcon />}
<div
className={styles.settingButton}
onClick={e => {
e.stopPropagation();
onSettingClick(meta);
}}
withoutHoverStyle={true}
>
<SettingsIcon />
</StyledSettingLink>
</StyledWorkspaceTitleArea>
<WorkspaceType isOwner={isOwner} flavour={meta.flavour} />
</StyledWorkspaceInfo>
</StyledCard>
<SettingsIcon width={16} height={16} />
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,86 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
import { displayFlex, textEllipsis } from '../../../styles';
export const card = style({
width: '100%',
cursor: 'pointer',
padding: '8px 12px',
borderRadius: 4,
// border: `1px solid ${borderColor}`,
boxShadow: 'inset 0 0 0 1px transparent',
...displayFlex('flex-start', 'flex-start'),
transition: 'background .2s',
position: 'relative',
color: cssVar('textSecondaryColor'),
background: 'transparent',
display: 'flex',
alignItems: 'center',
gap: 12,
selectors: {
'&:hover': {
background: cssVar('hoverColor'),
},
'&[data-active="true"]': {
boxShadow: 'inset 0 0 0 1px ' + cssVar('brandColor'),
},
},
});
export const workspaceInfo = style({
width: 0,
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
});
export const workspaceTitle = style({
width: 0,
flex: 1,
fontSize: cssVar('fontSm'),
fontWeight: 500,
lineHeight: '22px',
maxWidth: '190px',
color: cssVar('textPrimaryColor'),
...textEllipsis(1),
});
export const actionButtons = style({
display: 'flex',
alignItems: 'center',
});
export const settingButtonWrapper = style({});
export const settingButton = style({
transition: 'all 0.13s ease',
width: 0,
height: 20,
overflow: 'hidden',
marginLeft: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
placeItems: 'center',
borderRadius: 4,
boxShadow: 'none',
background: 'transparent',
cursor: 'pointer',
selectors: {
[`.${card}:hover &`]: {
width: 20,
marginLeft: 8,
boxShadow: cssVar('shadow1'),
background: cssVar('white80'),
},
// [`.${card}:hover &:hover`]: {
// background: cssVar('hoverColor'),
// },
},
});
@@ -1,136 +0,0 @@
import { displayFlex, styled, textEllipsis } from '../../../styles';
import { IconButton } from '../../../ui/button';
export const StyledWorkspaceInfo = styled('div')(() => {
return {
marginLeft: '12px',
width: '100%',
};
});
export const StyledWorkspaceTitle = styled('div')(() => {
return {
fontSize: 'var(--affine-font-sm)',
fontWeight: 700,
lineHeight: '22px',
maxWidth: '190px',
color: 'var(--affine-text-primary-color)',
...textEllipsis(1),
};
});
export const StyledCard = styled('div')<{
active?: boolean;
}>(({ active }) => {
const borderColor = active ? 'var(--affine-primary-color)' : 'transparent';
const backgroundColor = active ? 'var(--affine-white-30)' : 'transparent';
return {
width: '100%',
cursor: 'pointer',
padding: '12px',
borderRadius: '8px',
border: `1px solid ${borderColor}`,
...displayFlex('flex-start', 'flex-start'),
transition: 'background .2s',
alignItems: 'center',
position: 'relative',
color: 'var(--affine-text-secondary-color)',
background: backgroundColor,
':hover': {
background: 'var(--affine-hover-color)',
'.add-icon': {
borderColor: 'var(--affine-primary-color)',
color: 'var(--affine-primary-color)',
},
'.setting-entry': {
opacity: 1,
pointerEvents: 'auto',
backgroundColor: 'var(--affine-white-30)',
boxShadow: 'var(--affine-shadow-1)',
':hover': {
background:
'linear-gradient(0deg, var(--affine-hover-color) 0%, var(--affine-hover-color) 100%), var(--affine-white-30)',
},
},
},
'@media (max-width: 720px)': {
width: '100%',
},
};
});
export const StyledModalHeader = styled('div')(() => {
return {
width: '100%',
height: '72px',
position: 'absolute',
left: 0,
top: 0,
borderRadius: '24px 24px 0 0',
padding: '0 40px',
...displayFlex('space-between', 'center'),
};
});
export const StyledSettingLink = styled(IconButton)(() => {
return {
position: 'absolute',
right: '10px',
top: '10px',
opacity: 0,
borderRadius: '4px',
color: 'var(--affine-primary-color)',
pointerEvents: 'none',
transition: 'all .15s',
':hover': {
background: 'var(--affine-hover-color)',
},
};
});
export const StyledWorkspaceType = styled('div')(() => {
return {
...displayFlex('flex-start', 'center'),
width: '100%',
height: '20px',
};
});
export const StyledWorkspaceTitleArea = styled('div')(() => {
return {
display: 'flex',
justifyContent: 'space-between',
};
});
export const StyledWorkspaceTypeEllipse = styled('div')<{
cloud?: boolean;
}>(({ cloud }) => {
return {
width: '5px',
height: '5px',
borderRadius: '50%',
background: cloud
? 'var(--affine-palette-shape-blue)'
: 'var(--affine-palette-shape-green)',
};
});
export const StyledWorkspaceTypeText = styled('div')(() => {
return {
fontSize: '12px',
fontWeight: 500,
lineHeight: '20px',
marginLeft: '4px',
color: 'var(--affine-text-secondary-color)',
};
});
export const StyledIconContainer = styled('div')(() => {
return {
...displayFlex('flex-start', 'center'),
fontSize: '14px',
gap: '8px',
color: 'var(--affine-icon-secondary)',
};
});
@@ -171,7 +171,7 @@ legend {
outline: 0;
border: 0;
font-size: var(--affine-font-base);
font-family: var(--affine-font-family);
font-family: inherit;
font-feature-settings: 'calt' 0;
}
@@ -79,7 +79,6 @@ export const DefaultAvatarContainerStyle = style({
width: '100%',
height: '100%',
position: 'relative',
borderRadius: '50%',
overflow: 'hidden',
});
export const DefaultAvatarMiddleItemStyle = style({
@@ -155,6 +154,7 @@ export const avatarFallback = style({
width: '100%',
height: '100%',
borderRadius: '50%',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -27,7 +27,7 @@ export type CheckboxProps = Omit<
export const Checkbox = ({
checked,
onChange,
indeterminate: indeterminate,
indeterminate,
disabled,
animation,
name,
@@ -0,0 +1,17 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_19485_742)">
<rect width="20" height="20" rx="10" fill="currentColor" fill-opacity="0.1" />
<path
d="M10 10.9999C12.0829 10.9999 13.7714 9.29858 13.7714 7.1999C13.7714 5.10122 12.0829 3.3999 10 3.3999C7.91709 3.3999 6.22857 5.10122 6.22857 7.1999C6.22857 9.29858 7.91709 10.9999 10 10.9999Z"
fill="currentColor" fill-opacity="0.3" />
<path
d="M1.5 22.3999C1.33431 22.3999 1.19948 22.2649 1.20496 22.0993C1.36224 17.3416 5.23972 13.5332 10 13.5332C14.7603 13.5332 18.6378 17.3416 18.795 22.0993C18.8005 22.2649 18.6657 22.3999 18.5 22.3999H1.5Z"
fill="currentColor" fill-opacity="0.3" />
</g>
<defs>
<clipPath id="clip0_19485_742">
<rect width="20" height="20" rx="10" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 892 B

@@ -1,10 +1,9 @@
import { style } from '@vanilla-extract/css';
export const fallbackStyle = style({
margin: '5px 16px',
margin: '4px 16px',
height: '100%',
});
export const fallbackHeaderStyle = style({
height: '56px',
width: '100%',
display: 'flex',
alignItems: 'center',
@@ -19,7 +19,7 @@ import * as styles from './index.css';
import { UserAccountItem } from './user-account';
import { AFFiNEWorkspaceList } from './workspace-list';
const SignInItem = () => {
export const SignInItem = () => {
const setDisableCloudOpen = useSetAtom(openDisableCloudAlertModalAtom);
const setOpen = useSetAtom(authAtom);
@@ -1,73 +1,8 @@
import { IconButton } from '@affine/component/ui/button';
import { Divider } from '@affine/component/ui/divider';
import { Menu, MenuIcon, MenuItem } from '@affine/component/ui/menu';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
AccountIcon,
MoreHorizontalIcon,
SignOutIcon,
} from '@blocksuite/icons';
import { useSetAtom } from 'jotai';
import { useCallback } from 'react';
import {
openSettingModalAtom,
openSignOutModalAtom,
} from '../../../../../atoms';
import { UserPlanButton } from '../../../../affine/auth/user-plan-button';
import * as styles from './index.css';
const AccountMenu = ({ onEventEnd }: { onEventEnd?: () => void }) => {
const setSettingModalAtom = useSetAtom(openSettingModalAtom);
const setOpenSignOutModalAtom = useSetAtom(openSignOutModalAtom);
const onOpenAccountSetting = useCallback(() => {
setSettingModalAtom(prev => ({
...prev,
open: true,
activeTab: 'account',
}));
}, [setSettingModalAtom]);
const onOpenSignOutModal = useCallback(() => {
onEventEnd?.();
setOpenSignOutModalAtom(true);
}, [onEventEnd, setOpenSignOutModalAtom]);
const t = useAFFiNEI18N();
return (
<div>
<MenuItem
preFix={
<MenuIcon>
<AccountIcon />
</MenuIcon>
}
data-testid="workspace-modal-account-settings-option"
onClick={onOpenAccountSetting}
>
{t['com.affine.workspace.cloud.account.settings']()}
</MenuItem>
<Divider />
<MenuItem
preFix={
<MenuIcon>
<SignOutIcon />
</MenuIcon>
}
data-testid="workspace-modal-sign-out-option"
onClick={onOpenSignOutModal}
>
{t['com.affine.workspace.cloud.account.logout']()}
</MenuItem>
</div>
);
};
export const UserAccountItem = ({
email,
onEventEnd,
}: {
email: string;
onEventEnd?: () => void;
@@ -76,21 +11,8 @@ export const UserAccountItem = ({
<div className={styles.userAccountContainer}>
<div className={styles.leftContainer}>
<div className={styles.userEmail}>{email}</div>
<UserPlanButton />
</div>
<Menu
items={<AccountMenu onEventEnd={onEventEnd} />}
contentOptions={{
side: 'right',
sideOffset: 12,
}}
>
<IconButton
data-testid="workspace-modal-account-option"
icon={<MoreHorizontalIcon />}
type="plain"
/>
</Menu>
<UserPlanButton />
</div>
);
};
@@ -10,17 +10,21 @@ export const workspaceListWrapper = style({
display: 'flex',
width: '100%',
flexDirection: 'column',
gap: '4px',
gap: 2,
});
export const workspaceType = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 4,
padding: '0px 12px',
fontWeight: 500,
fontSize: cssVar('fontXs'),
lineHeight: '20px',
color: cssVar('textSecondaryColor'),
});
export const workspaceTypeIcon = style({
color: cssVar('iconSecondary'),
});
export const scrollbar = style({
transform: 'translateX(8px)',
width: '4px',
@@ -8,6 +8,7 @@ import {
} from '@affine/core/hooks/use-workspace-info';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloudWorkspaceIcon, LocalWorkspaceIcon } from '@blocksuite/icons';
import type { DragEndEvent } from '@dnd-kit/core';
import type { WorkspaceMetadata } from '@toeverything/infra';
import { useLiveData, useService, WorkspaceManager } from '@toeverything/infra';
@@ -49,6 +50,11 @@ const CloudWorkSpaceList = ({
return (
<div className={styles.workspaceListWrapper}>
<div className={styles.workspaceType}>
<CloudWorkspaceIcon
width={14}
height={14}
className={styles.workspaceTypeIcon}
/>
{t['com.affine.workspaceList.workspaceListType.cloud']()}
</div>
<WorkspaceList
@@ -81,6 +87,11 @@ const LocalWorkspaces = ({
return (
<div className={styles.workspaceListWrapper}>
<div className={styles.workspaceType}>
<LocalWorkspaceIcon
width={14}
height={14}
className={styles.workspaceTypeIcon}
/>
{t['com.affine.workspaceList.workspaceListType.local']()}
</div>
<WorkspaceList
@@ -1,7 +1,7 @@
import { Tooltip } from '@affine/component';
import { pushNotificationAtom } from '@affine/component/notification-center';
import { Avatar } from '@affine/component/ui/avatar';
import { Avatar, type AvatarProps } from '@affine/component/ui/avatar';
import { Loading } from '@affine/component/ui/loading';
import { Tooltip } from '@affine/component/ui/tooltip';
import { openSettingModalAtom } from '@affine/core/atoms';
import { useDocEngineStatus } from '@affine/core/hooks/affine/use-doc-engine-status';
import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner';
@@ -24,21 +24,15 @@ import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import { useSystemOnline } from '../../../../hooks/use-system-online';
import {
StyledSelectorContainer,
StyledSelectorWrapper,
StyledWorkspaceName,
StyledWorkspaceStatus,
} from './styles';
import * as styles from './styles.css';
// FIXME:
// 1. Remove mui style
// 2. Refactor the code to improve readability
const CloudWorkspaceStatus = () => {
return (
<>
<CloudWorkspaceIcon />
AFFiNE Cloud
Cloud
</>
);
};
@@ -196,21 +190,49 @@ const useSyncEngineSyncProgress = () => {
) : (
<LocalWorkspaceStatus />
),
active:
currentWorkspace.flavour === WorkspaceFlavour.AFFINE_CLOUD &&
(syncing || retrying || isOverCapacity),
};
};
const WorkspaceStatus = () => {
const { message, icon } = useSyncEngineSyncProgress();
const WorkspaceInfo = ({ name }: { name: string }) => {
const { message, icon, active } = useSyncEngineSyncProgress();
const currentWorkspace = useService(Workspace);
const isCloud = currentWorkspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
// to make sure that animation will play first time
const [delayActive, setDelayActive] = useState(false);
useEffect(() => {
setDelayActive(active);
}, [active]);
return (
<div style={{ display: 'flex' }}>
<Tooltip content={message}>
<StyledWorkspaceStatus>{icon}</StyledWorkspaceStatus>
</Tooltip>
<div className={styles.workspaceInfoSlider} data-active={delayActive}>
<div className={styles.workspaceInfoSlide}>
<div className={styles.workspaceInfo} data-type="normal">
<div className={styles.workspaceName} data-testid="workspace-name">
{name}
</div>
<div className={styles.workspaceStatus}>
{isCloud ? <CloudWorkspaceStatus /> : <LocalWorkspaceStatus />}
</div>
</div>
{/* when syncing/offline/... */}
<div className={styles.workspaceInfo} data-type="events">
<div className={styles.workspaceActiveStatus}>
<Tooltip content={message}>{icon}</Tooltip>
</div>
</div>
</div>
</div>
);
};
const avatarImageProps = {
style: { borderRadius: 3 },
} satisfies AvatarProps['imageProps'];
export const WorkspaceCard = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
@@ -227,7 +249,8 @@ export const WorkspaceCard = forwardRef<
const name = information?.name ?? UNTITLED_WORKSPACE_NAME;
return (
<StyledSelectorContainer
<div
className={styles.container}
role="button"
tabIndex={0}
data-testid="current-workspace"
@@ -236,19 +259,16 @@ export const WorkspaceCard = forwardRef<
{...props}
>
<Avatar
imageProps={avatarImageProps}
fallbackProps={avatarImageProps}
data-testid="workspace-avatar"
size={40}
size={32}
url={avatarUrl}
name={name}
colorfulFallback
/>
<StyledSelectorWrapper>
<StyledWorkspaceName data-testid="workspace-name">
{name}
</StyledWorkspaceName>
<WorkspaceStatus />
</StyledSelectorWrapper>
</StyledSelectorContainer>
<WorkspaceInfo name={name} />
</div>
);
});
@@ -0,0 +1,99 @@
import { cssVar } from '@toeverything/theme';
import { globalStyle, style } from '@vanilla-extract/css';
const wsSlideAnim = {
ease: 'cubic-bezier(.45,.21,0,1)',
duration: '0.5s',
delay: '0.23s',
};
export const container = style({
height: '50px',
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '0 6px',
borderRadius: 4,
outline: 'none',
width: '100%',
maxWidth: 500,
color: cssVar('textPrimaryColor'),
':hover': {
cursor: 'pointer',
background: cssVar('hoverColor'),
},
});
export const workspaceInfoSlider = style({
height: 42,
overflow: 'hidden',
});
export const workspaceInfoSlide = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
transform: 'translateY(0)',
transition: `transform ${wsSlideAnim.duration} ${wsSlideAnim.ease} ${wsSlideAnim.delay}`,
selectors: {
[`.${workspaceInfoSlider}[data-active="true"] &`]: {
transform: 'translateY(-42px)',
},
},
});
export const workspaceInfo = style({
width: '100%',
flexGrow: 1,
overflow: 'hidden',
height: 42,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
transition: `opacity ${wsSlideAnim.duration} ${wsSlideAnim.ease} ${wsSlideAnim.delay}`,
selectors: {
[`.${workspaceInfoSlider}[data-active="true"] &[data-type="normal"]`]: {
opacity: 0,
},
[`.${workspaceInfoSlider}[data-active="false"] &[data-type="events"]`]: {
opacity: 0,
},
},
});
export const workspaceName = style({
fontSize: cssVar('fontSm'),
lineHeight: '22px',
fontWeight: 500,
userSelect: 'none',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
export const workspaceStatus = style({
display: 'flex',
gap: 2,
alignItems: 'center',
fontSize: cssVar('fontXs'),
lineHeight: '20px',
fontWeight: 400,
color: cssVar('black50'),
});
globalStyle(`.${workspaceStatus} svg`, {
width: 16,
height: 16,
color: cssVar('iconSecondary'),
});
export const workspaceActiveStatus = style({
display: 'flex',
gap: 2,
alignItems: 'center',
fontSize: cssVar('fontSm'),
lineHeight: '22px',
color: cssVar('textSecondaryColor'),
});
globalStyle(`.${workspaceActiveStatus} svg`, {
width: 16,
height: 16,
});
@@ -1,16 +1,18 @@
import { displayFlex, styled, textEllipsis } from '@affine/component';
import { cssVar } from '@toeverything/theme';
export const StyledSelectorContainer = styled('div')({
height: '58px',
height: '50px',
display: 'flex',
alignItems: 'center',
padding: '0 6px',
borderRadius: '8px',
outline: 'none',
width: '100%',
color: 'var(--affine-text-primary-color)',
width: 'fit-content',
maxWidth: '100%',
color: cssVar('textPrimaryColor'),
':hover': {
cursor: 'pointer',
background: 'var(--affine-hover-color)',
background: cssVar('hoverColor'),
},
});
@@ -23,8 +25,9 @@ export const StyledSelectorWrapper = styled('div')(() => {
});
export const StyledWorkspaceName = styled('div')(() => {
return {
lineHeight: '24px',
fontWeight: 600,
fontSize: cssVar('fontSm'),
lineHeight: '22px',
fontWeight: 500,
userSelect: 'none',
...textEllipsis(1),
marginLeft: '4px',
@@ -35,17 +38,16 @@ export const StyledWorkspaceStatus = styled('div')(() => {
return {
height: '22px',
...displayFlex('flex-start', 'center'),
fontSize: 'var(--affine-font-sm)',
color: 'var(--affine-text-secondary-color)',
fontSize: cssVar('fontXs'),
color: cssVar('black50'),
userSelect: 'none',
padding: '0 4px',
gap: '4px',
zIndex: '1',
svg: {
color: 'var(--affine-icon-color)',
fontSize: 'var(--affine-font-base)',
color: cssVar('iconSecondary'),
'&[data-warning-color="true"]': {
color: 'var(--affine-error-color)',
color: cssVar('errorColor'),
},
},
};
@@ -0,0 +1,19 @@
import { style } from '@vanilla-extract/css';
export const workspaceAndUserWrapper = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
});
export const workspaceWrapper = style({
width: 0,
flex: 1,
});
export const userInfoWrapper = style({
flexShrink: 0,
width: 28,
height: 28,
});
@@ -42,8 +42,10 @@ import { AddFavouriteButton } from '../pure/workspace-slider-bar/favorite/add-fa
import FavoriteList from '../pure/workspace-slider-bar/favorite/favorite-list';
import { WorkspaceSelector } from '../workspace-selector';
import ImportPage from './import-page';
import { workspaceAndUserWrapper, workspaceWrapper } from './index.css';
import { AppSidebarJournalButton } from './journal-button';
import { UpdaterButton } from './updater-button';
import { UserInfo } from './user-info';
export type RootAppSidebarProps = {
isPublicWorkspace: boolean;
@@ -179,7 +181,12 @@ export const RootAppSidebar = ({
titles={deletePageTitles}
/>
<SidebarContainer>
<WorkspaceSelector />
<div className={workspaceAndUserWrapper}>
<div className={workspaceWrapper}>
<WorkspaceSelector />
</div>
<UserInfo />
</div>
<QuickSearchInput
data-testid="slider-bar-quick-search-button"
onClick={onOpenQuickSearchModal}
@@ -0,0 +1,130 @@
import {
Avatar,
Button,
Divider,
Menu,
MenuIcon,
MenuItem,
} from '@affine/component';
import {
authAtom,
openDisableCloudAlertModalAtom,
openSettingModalAtom,
openSignOutModalAtom,
} from '@affine/core/atoms';
import {
useCurrentUser,
useSession,
} from '@affine/core/hooks/affine/use-current-user';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { AccountIcon, SignOutIcon } from '@blocksuite/icons';
import { cssVar } from '@toeverything/theme';
import { useSetAtom } from 'jotai';
import { useCallback } from 'react';
import * as styles from './index.css';
export const UserInfo = () => {
const { status } = useSession();
const isAuthenticated = status === 'authenticated';
return isAuthenticated ? <AuthorizedUserInfo /> : <UnauthorizedUserInfo />;
};
const AuthorizedUserInfo = () => {
const user = useCurrentUser();
return (
<Menu items={<OperationMenu />}>
<Button
data-testid="sidebar-user-avatar"
type="plain"
className={styles.userInfoWrapper}
>
<Avatar size={20} name={user.name} url={user.avatarUrl} />
</Button>
</Menu>
);
};
const UnauthorizedUserInfo = () => {
const setDisableCloudOpen = useSetAtom(openDisableCloudAlertModalAtom);
const setOpen = useSetAtom(authAtom);
const openSignInModal = useCallback(() => {
if (!runtimeConfig.enableCloud) setDisableCloudOpen(true);
else setOpen(state => ({ ...state, openModal: true }));
}, [setDisableCloudOpen, setOpen]);
return (
<Button
onClick={openSignInModal}
data-testid="sidebar-user-avatar"
type="plain"
className={styles.userInfoWrapper}
>
<Avatar
style={{ color: cssVar('black') }}
size={20}
url={'/imgs/unknown-user.svg'}
/>
</Button>
);
};
const AccountMenu = () => {
const setSettingModalAtom = useSetAtom(openSettingModalAtom);
const setOpenSignOutModalAtom = useSetAtom(openSignOutModalAtom);
const onOpenAccountSetting = useCallback(() => {
setSettingModalAtom(prev => ({
...prev,
open: true,
activeTab: 'account',
}));
}, [setSettingModalAtom]);
const onOpenSignOutModal = useCallback(() => {
setOpenSignOutModalAtom(true);
}, [setOpenSignOutModalAtom]);
const t = useAFFiNEI18N();
return (
<>
<MenuItem
preFix={
<MenuIcon>
<AccountIcon />
</MenuIcon>
}
data-testid="workspace-modal-account-settings-option"
onClick={onOpenAccountSetting}
>
{t['com.affine.workspace.cloud.account.settings']()}
</MenuItem>
<Divider />
<MenuItem
preFix={
<MenuIcon>
<SignOutIcon />
</MenuIcon>
}
data-testid="workspace-modal-sign-out-option"
onClick={onOpenSignOutModal}
>
{t['com.affine.workspace.cloud.account.logout']()}
</MenuItem>
</>
);
};
const OperationMenu = () => {
// TODO: display usage progress bar
const StorageUsage = null;
return (
<>
{StorageUsage}
<AccountMenu />
</>
);
};
@@ -3,7 +3,7 @@ import { style } from '@vanilla-extract/css';
export const container = style({
display: 'flex',
alignItems: 'center',
columnGap: '32px',
columnGap: '8px',
});
export const button = style({
@@ -3,6 +3,7 @@ import type { PageRecordList } from '@toeverything/infra';
import { LiveData } from '@toeverything/infra';
import type { WorkspaceLegacyProperties } from '../../workspace';
import { tagColorMap } from './utils';
export class Tag {
constructor(
@@ -17,7 +18,7 @@ export class Tag {
value$ = this.tagOption$.map(tag => tag?.value || '');
color$ = this.tagOption$.map(tag => tag?.color || '');
color$ = this.tagOption$.map(tag => tagColorMap(tag?.color) || '');
createDate$ = this.tagOption$.map(tag => tag?.createDate || Date.now());
@@ -18,10 +18,7 @@ import { WorkspaceLayout } from '../../layouts/workspace-layout';
import { RightSidebarContainer } from '../../modules/right-sidebar';
import { WorkbenchRoot } from '../../modules/workbench';
import { CurrentWorkspaceService } from '../../modules/workspace/current-workspace';
import {
AllWorkspaceModals,
CurrentWorkspaceModals,
} from '../../providers/modal-provider';
import { AllWorkspaceModals } from '../../providers/modal-provider';
import { performanceRenderLogger } from '../../shared';
import { PageNotFound } from '../404';
@@ -92,7 +89,6 @@ export const Component = (): ReactElement => {
<ServiceProviderContext.Provider value={workspace.services}>
<WorkspaceFallback key="workspaceLoading" />
<AllWorkspaceModals />
<CurrentWorkspaceModals />
</ServiceProviderContext.Provider>
);
}
+4
View File
@@ -14,6 +14,10 @@ function RootRouter() {
useEffect(() => {
mixpanel.track_pageview({
page: location.pathname,
appVersion: runtimeConfig.appVersion,
environment: runtimeConfig.appBuildType,
editorVersion: runtimeConfig.editorVersion,
isSelfHosted: Boolean(runtimeConfig.isSelfHosted),
});
}, [location]);
return <Outlet />;
+1 -1
View File
@@ -61,6 +61,7 @@
"jotai-devtools": "^0.8.0",
"lodash-es": "^4.17.21",
"mixpanel-browser": "^2.49.0",
"nanoid": "^5.0.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.3",
@@ -79,7 +80,6 @@
"async-call-rpc": "^6.4.0",
"electron-updater": "^6.1.9",
"link-preview-js": "^3.0.5",
"nanoid": "^5.0.6",
"yjs": "^13.6.12"
},
"build": {
+1 -1
View File
@@ -39,7 +39,7 @@ export const config = (): BuildOptions => {
bundle: true,
target: `node${NODE_MAJOR_VERSION}`,
platform: 'node',
external: ['electron', 'electron-updater', 'yjs', 'semver', 'tinykeys'],
external: ['electron', 'electron-updater', 'yjs', 'semver'],
format: 'cjs',
loader: {
'.node': 'copy',
@@ -1,6 +1,6 @@
import type { InsertRow } from '@affine/native';
import { SqliteConnection, ValidationResult } from '@affine/native';
import { WorkspaceVersion } from '@toeverything/infra';
import { WorkspaceVersion } from '@toeverything/infra/blocksuite';
import { applyGuidCompatibilityFix, migrateToLatest } from '../db/migration';
import { logger } from '../logger';
@@ -8,7 +8,7 @@ import {
migrateGuidCompatibility,
migrateToSubdoc,
WorkspaceVersion,
} from '@toeverything/infra';
} from '@toeverything/infra/blocksuite';
import fs from 'fs-extra';
import { nanoid } from 'nanoid';
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs';
@@ -1,7 +1,7 @@
import path from 'node:path';
import { ValidationResult } from '@affine/native';
import { WorkspaceVersion } from '@toeverything/infra';
import { WorkspaceVersion } from '@toeverything/infra/blocksuite';
import fs from 'fs-extra';
import { nanoid } from 'nanoid';
@@ -1,7 +1,10 @@
import fs from 'node:fs';
import path from 'node:path';
import { AppConfigStorage, defaultAppConfig } from '@toeverything/infra';
import {
AppConfigStorage,
defaultAppConfig,
} from '@toeverything/infra/app-config-storage';
import { app } from 'electron';
const FILENAME = 'config.json';
@@ -183,8 +183,10 @@ let hiddenMacWindow: BrowserWindow | undefined;
*/
export async function initAndShowMainWindow() {
if (!browserWindow || (await browserWindow.then(w => w.isDestroyed()))) {
const additionalArguments = await getWindowAdditionalArguments();
browserWindow = createWindow(additionalArguments);
browserWindow = (async () => {
const additionalArguments = await getWindowAdditionalArguments();
return createWindow(additionalArguments);
})();
}
const mainWindow = await browserWindow;
+4 -1
View File
@@ -11,7 +11,10 @@
"outDir": "lib",
"moduleResolution": "node",
"resolveJsonModule": true,
"noImplicitOverride": true
"noImplicitOverride": true,
"paths": {
"@toeverything/infra/*": ["../../common/infra/src/*"]
}
},
"include": ["./src"],
"exclude": ["renderer", "node_modules", "lib", "dist", "**/__tests__/**/*"],
+6 -6
View File
@@ -1,11 +1,11 @@
/* eslint-disable simple-import-sort/imports */
// Auto generated, do not edit manually
import json_0 from './onboarding/W-d9_llZ6rE-qoTiHKTk4.snapshot.json';
import json_1 from './onboarding/info.json';
import json_2 from './onboarding/blob.json';
import json_0 from './onboarding/info.json';
import json_1 from './onboarding/blob.json';
import json_2 from './onboarding/W-d9_llZ6rE-qoTiHKTk4.snapshot.json';
export const onboarding = {
'W-d9_llZ6rE-qoTiHKTk4.snapshot.json': json_0,
'info.json': json_1,
'blob.json': json_2
'info.json': json_0,
'blob.json': json_1,
'W-d9_llZ6rE-qoTiHKTk4.snapshot.json': json_2
}
+5 -5
View File
@@ -11,6 +11,7 @@ import {
clickSideBarAllPageButton,
clickSideBarCurrentWorkspaceBanner,
clickSideBarSettingButton,
clickSideBarUseAvatar,
} from '@affine-test/kit/utils/sidebar';
import { createLocalWorkspace } from '@affine-test/kit/utils/workspace';
import { expect } from '@playwright/test';
@@ -69,14 +70,13 @@ test.describe('login first', () => {
);
await clickSideBarAllPageButton(page);
const currentUrl = page.url();
await clickSideBarCurrentWorkspaceBanner(page);
await page.getByTestId('workspace-modal-account-option').click();
await clickSideBarUseAvatar(page);
await page.getByTestId('workspace-modal-sign-out-option').click();
await page.getByTestId('confirm-sign-out-button').click();
await page.reload();
await clickSideBarCurrentWorkspaceBanner(page);
const signInButton = page.getByTestId('cloud-signin-button');
await expect(signInButton).toBeVisible();
await clickSideBarUseAvatar(page);
const authModal = page.getByTestId('auth-modal');
await expect(authModal).toBeVisible();
expect(page.url()).toBe(currentUrl);
});
+50 -36
View File
@@ -33,7 +33,8 @@ async function importImage(page: Page, url: string) {
},
[url]
);
await page.waitForTimeout(500);
// TODO: wait for image to be loaded more reliably
await page.waitForTimeout(1000);
}
async function closeImagePreviewModal(page: Page) {
@@ -53,7 +54,7 @@ test('image preview should be shown', async ({ page }) => {
await title.click();
await page.keyboard.press('Enter');
await importImage(page, 'http://localhost:8081/large-image.png');
await page.locator('img').first().dblclick();
await page.locator('affine-page-image').first().dblclick();
const locator = page.getByTestId('image-preview-modal');
await expect(locator).toBeVisible();
await closeImagePreviewModal(page);
@@ -70,11 +71,12 @@ test('image go left and right', async ({ page }) => {
await title.click();
await page.keyboard.press('Enter');
await importImage(page, 'http://localhost:8081/large-image.png');
await page.locator('img').first().dblclick();
await page.locator('affine-page-image').first().dblclick();
await page.waitForTimeout(500);
blobId = (await page
.getByTestId('image-preview-modal')
.locator('img')
.nth(1)
.first()
.getAttribute('data-blob-id')) as string;
expect(blobId).toBeTruthy();
await closeImagePreviewModal(page);
@@ -87,11 +89,12 @@ test('image go left and right', async ({ page }) => {
}
const locator = page.getByTestId('image-preview-modal');
await expect(locator).toBeHidden();
await page.locator('img').first().dblclick();
await page.locator('affine-page-image').first().dblclick();
await page.waitForTimeout(1000);
{
const newBlobId = (await locator
.locator('img[data-blob-id]')
const newBlobId = (await page
.getByTestId('image-preview-modal')
.locator('img')
.first()
.getAttribute('data-blob-id')) as string;
expect(newBlobId).not.toBe(blobId);
@@ -99,8 +102,9 @@ test('image go left and right', async ({ page }) => {
await page.keyboard.press('ArrowRight');
await page.waitForTimeout(1000);
{
const newBlobId = (await locator
.locator('img[data-blob-id]')
const newBlobId = (await page
.getByTestId('image-preview-modal')
.locator('img')
.first()
.getAttribute('data-blob-id')) as string;
expect(newBlobId).toBe(blobId);
@@ -117,11 +121,12 @@ test('image able to zoom in and out with mouse scroll', async ({ page }) => {
await title.click();
await page.keyboard.press('Enter');
await importImage(page, 'http://localhost:8081/large-image.png');
await page.locator('img').first().dblclick();
await page.locator('affine-page-image').first().dblclick();
await page.waitForTimeout(500);
blobId = (await page
.getByTestId('image-preview-modal')
.locator('img')
.nth(1)
.first()
.getAttribute('data-blob-id')) as string;
expect(blobId).toBeTruthy();
}
@@ -170,11 +175,12 @@ test('image able to zoom in and out with button click', async ({ page }) => {
await title.click();
await page.keyboard.press('Enter');
await importImage(page, 'http://localhost:8081/large-image.png');
await page.locator('img').first().dblclick();
await page.locator('affine-page-image').first().dblclick();
await page.waitForTimeout(500);
blobId = (await page
.getByTestId('image-preview-modal')
.locator('img')
.nth(1)
.first()
.getAttribute('data-blob-id')) as string;
expect(blobId).toBeTruthy();
}
@@ -216,11 +222,12 @@ test('image should able to go left and right by buttons', async ({ page }) => {
await title.click();
await page.keyboard.press('Enter');
await importImage(page, 'http://localhost:8081/large-image.png');
await page.locator('img').first().dblclick();
await page.locator('affine-page-image').first().dblclick();
await page.waitForTimeout(500);
blobId = (await page
.getByTestId('image-preview-modal')
.locator('img')
.nth(1)
.first()
.getAttribute('data-blob-id')) as string;
expect(blobId).toBeTruthy();
await closeImagePreviewModal(page);
@@ -232,7 +239,7 @@ test('image should able to go left and right by buttons', async ({ page }) => {
await importImage(page, 'http://localhost:8081/affine-preview.png');
}
const locator = page.getByTestId('image-preview-modal');
await page.locator('img').first().dblclick();
await page.locator('affine-page-image').first().dblclick();
await expect(locator).toBeVisible();
{
const newBlobId = (await locator
@@ -268,11 +275,12 @@ test('image able to fit to screen by button', async ({ page }) => {
await title.click();
await page.keyboard.press('Enter');
await importImage(page, 'http://localhost:8081/large-image.png');
await page.locator('img').first().dblclick();
await page.locator('affine-page-image').first().dblclick();
await page.waitForTimeout(500);
blobId = (await page
.getByTestId('image-preview-modal')
.locator('img')
.nth(1)
.first()
.getAttribute('data-blob-id')) as string;
expect(blobId).toBeTruthy();
}
@@ -325,11 +333,12 @@ test('image able to reset zoom to 100%', async ({ page }) => {
await title.click();
await page.keyboard.press('Enter');
await importImage(page, 'http://localhost:8081/large-image.png');
await page.locator('img').first().dblclick();
await page.locator('affine-page-image').first().dblclick();
await page.waitForTimeout(500);
blobId = (await page
.getByTestId('image-preview-modal')
.locator('img')
.nth(1)
.first()
.getAttribute('data-blob-id')) as string;
expect(blobId).toBeTruthy();
}
@@ -378,11 +387,12 @@ test('image able to copy to clipboard', async ({ page }) => {
await title.click();
await page.keyboard.press('Enter');
await importImage(page, 'http://localhost:8081/large-image.png');
await page.locator('img').first().dblclick();
await page.locator('affine-page-image').first().dblclick();
await page.waitForTimeout(500);
blobId = (await page
.getByTestId('image-preview-modal')
.locator('img')
.nth(1)
.first()
.getAttribute('data-blob-id')) as string;
expect(blobId).toBeTruthy();
}
@@ -407,11 +417,12 @@ test('image able to download', async ({ page }) => {
await title.click();
await page.keyboard.press('Enter');
await importImage(page, 'http://localhost:8081/large-image.png');
await page.locator('img').first().dblclick();
await page.locator('affine-page-image').first().dblclick();
await page.waitForTimeout(500);
blobId = (await page
.getByTestId('image-preview-modal')
.locator('img')
.nth(1)
.first()
.getAttribute('data-blob-id')) as string;
expect(blobId).toBeTruthy();
}
@@ -437,11 +448,12 @@ test('image should only able to move when image is larger than viewport', async
await title.click();
await page.keyboard.press('Enter');
await importImage(page, 'http://localhost:8081/large-image.png');
await page.locator('img').first().dblclick();
await page.locator('affine-page-image').first().dblclick();
await page.waitForTimeout(500);
blobId = (await page
.getByTestId('image-preview-modal')
.locator('img')
.nth(1)
.first()
.getAttribute('data-blob-id')) as string;
expect(blobId).toBeTruthy();
}
@@ -494,11 +506,12 @@ test('image should able to delete and when delete, it will move to previous/next
await title.click();
await page.keyboard.press('Enter');
await importImage(page, 'http://localhost:8081/large-image.png');
await page.locator('img').first().dblclick();
await page.locator('affine-page-image').first().dblclick();
await page.waitForTimeout(500);
blobId = (await page
.getByTestId('image-preview-modal')
.locator('img')
.nth(1)
.first()
.getAttribute('data-blob-id')) as string;
expect(blobId).toBeTruthy();
await closeImagePreviewModal(page);
@@ -510,7 +523,7 @@ test('image should able to delete and when delete, it will move to previous/next
await importImage(page, 'http://localhost:8081/affine-preview.png');
}
const locator = page.getByTestId('image-preview-modal');
await page.locator('img').first().dblclick();
await page.locator('affine-page-image').first().dblclick();
await expect(locator).toBeVisible();
// ensure the new image was imported
await page.waitForTimeout(1000);
@@ -533,7 +546,7 @@ test('image should able to delete and when delete, it will move to previous/next
await page.keyboard.press('Enter');
await importImage(page, 'http://localhost:8081/affine-preview.png');
}
await page.locator('img').first().dblclick();
await page.locator('affine-page-image').first().dblclick();
await locator.getByTestId('next-image-button').click();
await page.waitForTimeout(1000);
{
@@ -569,11 +582,12 @@ test('tooltips for all buttons should be visible when hovering', async ({
await title.click();
await page.keyboard.press('Enter');
await importImage(page, 'http://localhost:8081/large-image.png');
await page.locator('img').first().dblclick();
await page.locator('affine-page-image').first().dblclick();
await page.waitForTimeout(500);
blobId = (await page
.getByTestId('image-preview-modal')
.locator('img')
.nth(1)
.first()
.getAttribute('data-blob-id')) as string;
expect(blobId).toBeTruthy();
}
@@ -662,7 +676,7 @@ test('keypress esc should close the modal', async ({ page }) => {
await title.click();
await page.keyboard.press('Enter');
await importImage(page, 'http://localhost:8081/large-image.png');
await page.locator('img').first().dblclick();
await page.locator('affine-page-image').first().dblclick();
const locator = page.getByTestId('image-preview-modal');
await expect(locator).toBeVisible();
await page.keyboard.press('Escape');
@@ -680,7 +694,7 @@ test('when mouse moves outside, the modal should be closed', async ({
await title.click();
await page.keyboard.press('Enter');
await importImage(page, 'http://localhost:8081/large-image.png');
await page.locator('img').first().dblclick();
await page.locator('affine-page-image').first().dblclick();
const locator = page.getByTestId('image-preview-modal');
await expect(locator).toBeVisible();
// animation delay
@@ -701,14 +715,14 @@ test('caption should be visible and different styles were applied if image zoome
await title.click();
await page.keyboard.press('Enter');
await importImage(page, 'http://localhost:8081/large-image.png');
await page.locator('img').first().hover();
await page.locator('affine-page-image').first().hover();
await page
.locator('.embed-editing-state')
.locator('icon-button')
.nth(1)
.click();
await page.getByPlaceholder('Write a caption').fill(sampleCaption);
await page.locator('img').first().dblclick();
await page.locator('affine-page-image').first().dblclick();
const locator = page.getByTestId('image-preview-modal');
await expect(locator).toBeVisible();
await page.waitForTimeout(1000);
+4
View File
@@ -12,6 +12,10 @@ export async function clickSideBarCurrentWorkspaceBanner(page: Page) {
return page.getByTestId('current-workspace').click();
}
export async function clickSideBarUseAvatar(page: Page) {
return page.getByTestId('sidebar-user-avatar').click();
}
export async function clickNewPageButton(page: Page) {
return page.getByTestId('sidebar-new-page-button').click();
}
+1
View File
@@ -349,6 +349,7 @@ export const createConfiguration: (
),
'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN),
'process.env.BUILD_TYPE': JSON.stringify(process.env.BUILD_TYPE),
'process.env.MIXPANEL_TOKEN': `"${process.env.MIXPANEL_TOKEN}"`,
runtimeConfig: JSON.stringify(runtimeConfig),
}),
new CopyPlugin({
+93 -34
View File
@@ -602,7 +602,7 @@ __metadata:
nanoid: "npm:^5.0.6"
nx: "npm:^18.0.4"
nyc: "npm:^15.1.0"
oxlint: "npm:0.0.22"
oxlint: "npm:0.2.14"
prettier: "npm:^3.2.5"
semver: "npm:^7.6.0"
serve: "npm:^14.2.1"
@@ -9400,44 +9400,58 @@ __metadata:
languageName: node
linkType: hard
"@oxlint/darwin-arm64@npm:0.0.22":
version: 0.0.22
resolution: "@oxlint/darwin-arm64@npm:0.0.22"
"@oxlint/darwin-arm64@npm:0.2.14":
version: 0.2.14
resolution: "@oxlint/darwin-arm64@npm:0.2.14"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@oxlint/darwin-x64@npm:0.0.22":
version: 0.0.22
resolution: "@oxlint/darwin-x64@npm:0.0.22"
"@oxlint/darwin-x64@npm:0.2.14":
version: 0.2.14
resolution: "@oxlint/darwin-x64@npm:0.2.14"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@oxlint/linux-arm64@npm:0.0.22":
version: 0.0.22
resolution: "@oxlint/linux-arm64@npm:0.0.22"
conditions: os=linux & cpu=arm64
"@oxlint/linux-arm64-gnu@npm:0.2.14":
version: 0.2.14
resolution: "@oxlint/linux-arm64-gnu@npm:0.2.14"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@oxlint/linux-x64@npm:0.0.22":
version: 0.0.22
resolution: "@oxlint/linux-x64@npm:0.0.22"
conditions: os=linux & cpu=x64
"@oxlint/linux-arm64-musl@npm:0.2.14":
version: 0.2.14
resolution: "@oxlint/linux-arm64-musl@npm:0.2.14"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@oxlint/win32-arm64@npm:0.0.22":
version: 0.0.22
resolution: "@oxlint/win32-arm64@npm:0.0.22"
"@oxlint/linux-x64-gnu@npm:0.2.14":
version: 0.2.14
resolution: "@oxlint/linux-x64-gnu@npm:0.2.14"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@oxlint/linux-x64-musl@npm:0.2.14":
version: 0.2.14
resolution: "@oxlint/linux-x64-musl@npm:0.2.14"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@oxlint/win32-arm64@npm:0.2.14":
version: 0.2.14
resolution: "@oxlint/win32-arm64@npm:0.2.14"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@oxlint/win32-x64@npm:0.0.22":
version: 0.0.22
resolution: "@oxlint/win32-x64@npm:0.0.22"
"@oxlint/win32-x64@npm:0.2.14":
version: 0.2.14
resolution: "@oxlint/win32-x64@npm:0.2.14"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
@@ -21139,7 +21153,7 @@ __metadata:
languageName: node
linkType: hard
"express@npm:4.18.2, express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.2":
"express@npm:4.18.2":
version: 4.18.2
resolution: "express@npm:4.18.2"
dependencies:
@@ -21178,6 +21192,45 @@ __metadata:
languageName: node
linkType: hard
"express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.2":
version: 4.19.2
resolution: "express@npm:4.19.2"
dependencies:
accepts: "npm:~1.3.8"
array-flatten: "npm:1.1.1"
body-parser: "npm:1.20.2"
content-disposition: "npm:0.5.4"
content-type: "npm:~1.0.4"
cookie: "npm:0.6.0"
cookie-signature: "npm:1.0.6"
debug: "npm:2.6.9"
depd: "npm:2.0.0"
encodeurl: "npm:~1.0.2"
escape-html: "npm:~1.0.3"
etag: "npm:~1.8.1"
finalhandler: "npm:1.2.0"
fresh: "npm:0.5.2"
http-errors: "npm:2.0.0"
merge-descriptors: "npm:1.0.1"
methods: "npm:~1.1.2"
on-finished: "npm:2.4.1"
parseurl: "npm:~1.3.3"
path-to-regexp: "npm:0.1.7"
proxy-addr: "npm:~2.0.7"
qs: "npm:6.11.0"
range-parser: "npm:~1.2.1"
safe-buffer: "npm:5.2.1"
send: "npm:0.18.0"
serve-static: "npm:1.15.0"
setprototypeof: "npm:1.2.0"
statuses: "npm:2.0.1"
type-is: "npm:~1.6.18"
utils-merge: "npm:1.0.1"
vary: "npm:~1.1.2"
checksum: 10/3fcd792536f802c059789ef48db3851b87e78fba103423e524144d79af37da7952a2b8d4e1a007f423329c7377d686d9476ac42e7d9ea413b80345d495e30a3a
languageName: node
linkType: hard
"extend@npm:^3.0.0, extend@npm:^3.0.2":
version: 3.0.2
resolution: "extend@npm:3.0.2"
@@ -28914,24 +28967,30 @@ __metadata:
languageName: node
linkType: hard
"oxlint@npm:0.0.22":
version: 0.0.22
resolution: "oxlint@npm:0.0.22"
"oxlint@npm:0.2.14":
version: 0.2.14
resolution: "oxlint@npm:0.2.14"
dependencies:
"@oxlint/darwin-arm64": "npm:0.0.22"
"@oxlint/darwin-x64": "npm:0.0.22"
"@oxlint/linux-arm64": "npm:0.0.22"
"@oxlint/linux-x64": "npm:0.0.22"
"@oxlint/win32-arm64": "npm:0.0.22"
"@oxlint/win32-x64": "npm:0.0.22"
"@oxlint/darwin-arm64": "npm:0.2.14"
"@oxlint/darwin-x64": "npm:0.2.14"
"@oxlint/linux-arm64-gnu": "npm:0.2.14"
"@oxlint/linux-arm64-musl": "npm:0.2.14"
"@oxlint/linux-x64-gnu": "npm:0.2.14"
"@oxlint/linux-x64-musl": "npm:0.2.14"
"@oxlint/win32-arm64": "npm:0.2.14"
"@oxlint/win32-x64": "npm:0.2.14"
dependenciesMeta:
"@oxlint/darwin-arm64":
optional: true
"@oxlint/darwin-x64":
optional: true
"@oxlint/linux-arm64":
"@oxlint/linux-arm64-gnu":
optional: true
"@oxlint/linux-x64":
"@oxlint/linux-arm64-musl":
optional: true
"@oxlint/linux-x64-gnu":
optional: true
"@oxlint/linux-x64-musl":
optional: true
"@oxlint/win32-arm64":
optional: true
@@ -28939,7 +28998,7 @@ __metadata:
optional: true
bin:
oxlint: bin/oxlint
checksum: 10/9d780f3293b08c83e45996187c8098e2ef8323ed02427c7a15d5cfa542f967b0e7862f76814ef3b8c4b94badd2f21eb92130de7f2f8f0dfec91e4a3a3797c63f
checksum: 10/18c46a5adfa7477d6aa0be095fd913efd5b6701873f98d31d95f28817b7bb6590e1f1bc1b774dd4aa2166290c51482921a7ada7d86d0aaf16d9ab8d804dde8cf
languageName: node
linkType: hard