mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-14 00:26:51 +08:00
Merge remote-tracking branch 'origin/canary' into beta
This commit is contained in:
+2
-2
@@ -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);
|
||||
});
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
+1
-1
@@ -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
-79
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+6
-2
@@ -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',
|
||||
|
||||
+11
@@ -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
|
||||
|
||||
+45
-25
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+99
@@ -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,
|
||||
});
|
||||
+13
-11
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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__/**/*"],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user