mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 08:38:34 +00:00
Compare commits
86 Commits
v0.16.0-be
...
v0.16.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
171a974904 | ||
|
|
0ec1995add | ||
|
|
6dea831d8a | ||
|
|
b214003968 | ||
|
|
bf6e36de37 | ||
|
|
7f7c0519a0 | ||
|
|
83a9beed83 | ||
|
|
1db6b9fe3b | ||
|
|
ccf225c8f9 | ||
|
|
dc519348c5 | ||
|
|
10f4eaf2bd | ||
|
|
d365494fef | ||
|
|
69c64b2fc2 | ||
|
|
dc41ffbe2f | ||
|
|
9037e6695e | ||
|
|
6228b27271 | ||
|
|
75e02bb088 | ||
|
|
4ac9bd7790 | ||
|
|
a6169ab26a | ||
|
|
d82f4b5461 | ||
|
|
a579cc7716 | ||
|
|
b993ab04df | ||
|
|
eef9afd3ed | ||
|
|
06d5d9719c | ||
|
|
f8e51112aa | ||
|
|
e8d5692062 | ||
|
|
d2b0ee40a8 | ||
|
|
3ad5170b71 | ||
|
|
8209e84842 | ||
|
|
fc19180451 | ||
|
|
009b5353b1 | ||
|
|
4beedaa22c | ||
|
|
26fd9a4a1c | ||
|
|
b2c00a2618 | ||
|
|
85637156f6 | ||
|
|
c006f3f0af | ||
|
|
7efc87b6d3 | ||
|
|
450106ea54 | ||
|
|
ffc12176c9 | ||
|
|
3d4fbcaebc | ||
|
|
8db37e9bbf | ||
|
|
7fca13076a | ||
|
|
fd6e198295 | ||
|
|
b71945c29f | ||
|
|
6ef5675be1 | ||
|
|
c7aabd3a8d | ||
|
|
03fd23de39 | ||
|
|
f2eafc374c | ||
|
|
83244f0201 | ||
|
|
f62d30527b | ||
|
|
025abc6169 | ||
|
|
58b43582e1 | ||
|
|
ff68efb206 | ||
|
|
c8f4766ceb | ||
|
|
d968cfe425 | ||
|
|
2f0e39b702 | ||
|
|
4e03edba44 | ||
|
|
00ee2a8852 | ||
|
|
75a308ac79 | ||
|
|
f35dc744dd | ||
|
|
ae9381c36d | ||
|
|
e1087a0c7b | ||
|
|
eb01e76426 | ||
|
|
67dce9c97a | ||
|
|
7edd78884e | ||
|
|
74025fc85e | ||
|
|
b5e543c406 | ||
|
|
352ceca94b | ||
|
|
f3855c57b4 | ||
|
|
f6279ee47f | ||
|
|
aee24ffb31 | ||
|
|
96fed60655 | ||
|
|
dd74cfea14 | ||
|
|
c2cf331ff7 | ||
|
|
744cc542de | ||
|
|
601f5fef95 | ||
|
|
14669b9ced | ||
|
|
5872b884a5 | ||
|
|
d0f1bb24fd | ||
|
|
7373e174db | ||
|
|
cc09085dc2 | ||
|
|
f93743dae6 | ||
|
|
de7933c1dd | ||
|
|
ca7c221d23 | ||
|
|
873e6faef2 | ||
|
|
5938d8b259 |
@@ -247,7 +247,8 @@ const config = {
|
||||
'react-hooks/exhaustive-deps': [
|
||||
'warn',
|
||||
{
|
||||
additionalHooks: '(useAsyncCallback|useDraggable|useDropTarget)',
|
||||
additionalHooks:
|
||||
'(useAsyncCallback|useCatchEventCallback|useDraggable|useDropTarget)',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
2
.github/helm/affine/Chart.yaml
vendored
2
.github/helm/affine/Chart.yaml
vendored
@@ -3,4 +3,4 @@ name: affine
|
||||
description: AFFiNE cloud chart
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.15.0"
|
||||
appVersion: "0.16.0"
|
||||
|
||||
@@ -3,7 +3,7 @@ name: graphql
|
||||
description: AFFiNE GraphQL server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.15.0"
|
||||
appVersion: "0.16.0"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
2
.github/helm/affine/charts/sync/Chart.yaml
vendored
2
.github/helm/affine/charts/sync/Chart.yaml
vendored
@@ -3,7 +3,7 @@ name: sync
|
||||
description: AFFiNE Sync Server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.15.0"
|
||||
appVersion: "0.16.0"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
<div align="center">
|
||||
<a href="https://affine.pro">Home Page</a> |
|
||||
<a href="https://discord.com/invite/yz6tGVsf5p">Discord</a> |
|
||||
<a href="https://discord.gg/whd5mjYqVw">Discord</a> |
|
||||
<a href="https://app.affine.pro">Live Demo</a> |
|
||||
<a href="https://affine.pro/blog/">Blog</a> |
|
||||
<a href="https://docs.affine.pro/docs/">Documentation</a>
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
],
|
||||
"ext": "ts,md,json"
|
||||
},
|
||||
"version": "0.15.0"
|
||||
"version": "0.16.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/monorepo",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"private": true,
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
@@ -108,7 +108,7 @@
|
||||
"vite-plugin-static-copy": "^1.0.2",
|
||||
"vitest": "1.6.0",
|
||||
"vitest-fetch-mock": "^0.3.0",
|
||||
"vitest-mock-extended": "^1.3.1"
|
||||
"vitest-mock-extended": "^2.0.0"
|
||||
},
|
||||
"packageManager": "yarn@4.3.1",
|
||||
"resolutions": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/server-native",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"engines": {
|
||||
"node": ">= 10.16.0 < 11 || >= 11.8.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `snapshot_histories` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "_data_migrations" ALTER COLUMN "started_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "finished_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_prompts_messages" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_prompts_metadata" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_sessions_messages" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_sessions_metadata" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "app_runtime_settings" ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "features" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "multiple_users_sessions" ALTER COLUMN "expires_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "snapshot_histories" ALTER COLUMN "timestamp" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "expired_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "snapshots" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "updates" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_connected_accounts" ALTER COLUMN "expires_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_features" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "expired_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_invoices" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_sessions" ALTER COLUMN "expires_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_stripe_customers" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_subscriptions" ALTER COLUMN "start" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "end" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "next_bill_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "canceled_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "trial_start" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "trial_end" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "email_verified" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "verification_tokens" ALTER COLUMN "expiresAt" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspace_features" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
|
||||
ALTER COLUMN "expired_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspace_page_user_permissions" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspace_user_permissions" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspaces" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@affine/server",
|
||||
"private": true,
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"description": "Affine Node.js server",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -13,9 +13,9 @@ model User {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
name String @db.VarChar
|
||||
email String @unique @db.VarChar
|
||||
emailVerifiedAt DateTime? @map("email_verified") @db.Timestamp(3)
|
||||
emailVerifiedAt DateTime? @map("email_verified") @db.Timestamptz(3)
|
||||
avatarUrl String? @map("avatar_url") @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
/// Not available if user signed up through OAuth providers
|
||||
password String? @db.VarChar
|
||||
/// Indicate whether the user finished the signup progress.
|
||||
@@ -45,9 +45,9 @@ model ConnectedAccount {
|
||||
scope String? @db.Text
|
||||
accessToken String? @map("access_token") @db.Text
|
||||
refreshToken String? @map("refresh_token") @db.Text
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(3)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -58,8 +58,8 @@ model ConnectedAccount {
|
||||
|
||||
model Session {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
userSessions UserSession[]
|
||||
|
||||
@@ -70,8 +70,8 @@ model UserSession {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
sessionId String @map("session_id") @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@ -84,7 +84,7 @@ model VerificationToken {
|
||||
token String @db.VarChar
|
||||
type Int @db.SmallInt
|
||||
credential String? @db.Text
|
||||
expiresAt DateTime @db.Timestamp(3)
|
||||
expiresAt DateTime @db.Timestamptz(3)
|
||||
|
||||
@@unique([type, token])
|
||||
@@map("verification_tokens")
|
||||
@@ -93,7 +93,7 @@ model VerificationToken {
|
||||
model Workspace {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
public Boolean
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
pages WorkspacePage[]
|
||||
permissions WorkspaceUserPermission[]
|
||||
@@ -129,7 +129,7 @@ model WorkspaceUserPermission {
|
||||
type Int @db.SmallInt
|
||||
/// Whether the permission invitation is accepted by the user
|
||||
accepted Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
@@ -147,7 +147,7 @@ model WorkspacePageUserPermission {
|
||||
type Int @db.SmallInt
|
||||
/// Whether the permission invitation is accepted by the user
|
||||
accepted Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
@@ -170,9 +170,9 @@ model UserFeature {
|
||||
// - pro_plan_v1: "user buy the pro plan"
|
||||
reason String @db.VarChar
|
||||
// record the quota enabled time
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
// record the quota expired time, pay plan is a subscription, so it will expired
|
||||
expiredAt DateTime? @map("expired_at") @db.Timestamp(3)
|
||||
expiredAt DateTime? @map("expired_at") @db.Timestamptz(3)
|
||||
// whether the feature is activated
|
||||
// for example:
|
||||
// - if we switch the user to another plan, we will set the old plan to deactivated, but dont delete it
|
||||
@@ -198,9 +198,9 @@ model WorkspaceFeature {
|
||||
// - copilet_v1: "owner buy the copilet feature package"
|
||||
reason String @db.VarChar
|
||||
// record the feature enabled time
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
// record the quota expired time, pay plan is a subscription, so it will expired
|
||||
expiredAt DateTime? @map("expired_at") @db.Timestamp(3)
|
||||
expiredAt DateTime? @map("expired_at") @db.Timestamptz(3)
|
||||
// whether the feature is activated
|
||||
// for example:
|
||||
// - if owner unsubscribe a feature package, we will set the feature to deactivated, but dont delete it
|
||||
@@ -220,7 +220,7 @@ model Feature {
|
||||
type Int @db.Integer
|
||||
// configs, define by feature conntroller
|
||||
configs Json @db.Json
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
UserFeatureGates UserFeature[]
|
||||
WorkspaceFeatures WorkspaceFeature[]
|
||||
@@ -237,10 +237,10 @@ model Snapshot {
|
||||
blob Bytes @db.ByteA
|
||||
seq Int @default(0) @db.Integer
|
||||
state Bytes? @db.ByteA
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
// the `updated_at` field will not record the time of record changed,
|
||||
// but the created time of last seen update that has been merged into snapshot.
|
||||
updatedAt DateTime @map("updated_at") @db.Timestamp(3)
|
||||
updatedAt DateTime @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
@@id([id, workspaceId])
|
||||
@@map("snapshots")
|
||||
@@ -251,7 +251,7 @@ model Update {
|
||||
id String @map("guid") @db.VarChar
|
||||
seq Int @db.Integer
|
||||
blob Bytes @db.ByteA
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
@@id([workspaceId, id, seq])
|
||||
@@map("updates")
|
||||
@@ -260,10 +260,10 @@ model Update {
|
||||
model SnapshotHistory {
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
id String @map("guid") @db.VarChar
|
||||
timestamp DateTime @db.Timestamp(3)
|
||||
timestamp DateTime @db.Timestamptz(3)
|
||||
blob Bytes @db.ByteA
|
||||
state Bytes? @db.ByteA
|
||||
expiredAt DateTime @map("expired_at") @db.Timestamp(3)
|
||||
expiredAt DateTime @map("expired_at") @db.Timestamptz(3)
|
||||
|
||||
@@id([workspaceId, id, timestamp])
|
||||
@@map("snapshot_histories")
|
||||
@@ -272,7 +272,7 @@ model SnapshotHistory {
|
||||
model UserStripeCustomer {
|
||||
userId String @id @map("user_id") @db.VarChar
|
||||
stripeCustomerId String @unique @map("stripe_customer_id") @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -290,21 +290,21 @@ model UserSubscription {
|
||||
// subscription.status, active/past_due/canceled/unpaid...
|
||||
status String @db.VarChar(20)
|
||||
// subscription.current_period_start
|
||||
start DateTime @map("start") @db.Timestamp(3)
|
||||
start DateTime @map("start") @db.Timestamptz(3)
|
||||
// subscription.current_period_end, null for lifetime payment
|
||||
end DateTime? @map("end") @db.Timestamp(3)
|
||||
end DateTime? @map("end") @db.Timestamptz(3)
|
||||
// subscription.billing_cycle_anchor
|
||||
nextBillAt DateTime? @map("next_bill_at") @db.Timestamp(3)
|
||||
nextBillAt DateTime? @map("next_bill_at") @db.Timestamptz(3)
|
||||
// subscription.canceled_at
|
||||
canceledAt DateTime? @map("canceled_at") @db.Timestamp(3)
|
||||
canceledAt DateTime? @map("canceled_at") @db.Timestamptz(3)
|
||||
// subscription.trial_start
|
||||
trialStart DateTime? @map("trial_start") @db.Timestamp(3)
|
||||
trialStart DateTime? @map("trial_start") @db.Timestamptz(3)
|
||||
// subscription.trial_end
|
||||
trialEnd DateTime? @map("trial_end") @db.Timestamp(3)
|
||||
trialEnd DateTime? @map("trial_end") @db.Timestamptz(3)
|
||||
stripeScheduleId String? @map("stripe_schedule_id") @db.VarChar
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, plan])
|
||||
@@ -321,8 +321,8 @@ model UserInvoice {
|
||||
status String @db.VarChar(20)
|
||||
plan String @db.VarChar(20)
|
||||
recurring String @db.VarChar(20)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
// billing reason
|
||||
reason String @db.VarChar
|
||||
lastPaymentError String? @map("last_payment_error") @db.Text
|
||||
@@ -350,7 +350,7 @@ model AiPromptMessage {
|
||||
content String @db.Text
|
||||
attachments Json? @db.Json
|
||||
params Json? @db.Json
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
prompt AiPrompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -366,7 +366,7 @@ model AiPrompt {
|
||||
action String? @db.VarChar
|
||||
model String @db.VarChar
|
||||
config Json? @db.Json
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
messages AiPromptMessage[]
|
||||
sessions AiSession[]
|
||||
@@ -381,8 +381,8 @@ model AiSessionMessage {
|
||||
content String @db.Text
|
||||
attachments Json? @db.Json
|
||||
params Json? @db.Json
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
session AiSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -399,8 +399,8 @@ model AiSession {
|
||||
parentSessionId String? @map("parent_session_id") @db.VarChar
|
||||
messageCost Int @default(0)
|
||||
tokenCost Int @default(0)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
prompt AiPrompt @relation(fields: [promptName], references: [name], onDelete: Cascade)
|
||||
@@ -412,8 +412,8 @@ model AiSession {
|
||||
model DataMigration {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
name String @db.VarChar
|
||||
startedAt DateTime @default(now()) @map("started_at") @db.Timestamp(3)
|
||||
finishedAt DateTime? @map("finished_at") @db.Timestamp(3)
|
||||
startedAt DateTime @default(now()) @map("started_at") @db.Timestamptz(3)
|
||||
finishedAt DateTime? @map("finished_at") @db.Timestamptz(3)
|
||||
|
||||
@@map("_data_migrations")
|
||||
}
|
||||
@@ -433,8 +433,8 @@ model RuntimeConfig {
|
||||
key String @db.VarChar
|
||||
value Json @db.Json
|
||||
description String @db.Text
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamp(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
lastUpdatedBy String? @map("last_updated_by") @db.VarChar
|
||||
|
||||
lastUpdatedByUser User? @relation(fields: [lastUpdatedBy], references: [id])
|
||||
|
||||
@@ -152,14 +152,16 @@ function buildAppModule() {
|
||||
factor
|
||||
// common fundamental modules
|
||||
.use(...FunctionalityModules)
|
||||
.useIf(config => config.flavor.sync, WebSocketModule)
|
||||
|
||||
// auth
|
||||
.use(AuthModule)
|
||||
.use(UserModule, AuthModule)
|
||||
|
||||
// business modules
|
||||
.use(DocModule)
|
||||
|
||||
// sync server only
|
||||
.useIf(config => config.flavor.sync, WebSocketModule, SyncModule)
|
||||
.useIf(config => config.flavor.sync, SyncModule)
|
||||
|
||||
// graphql server only
|
||||
.useIf(
|
||||
@@ -167,7 +169,6 @@ function buildAppModule() {
|
||||
ServerConfigModule,
|
||||
GqlModule,
|
||||
StorageModule,
|
||||
UserModule,
|
||||
WorkspaceModule,
|
||||
FeatureModule,
|
||||
QuotaModule
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function createApp() {
|
||||
graphqlUploadExpress({
|
||||
// TODO(@darkskygit): dynamic limit by quota maybe?
|
||||
maxFileSize: 100 * 1024 * 1024,
|
||||
maxFiles: 5,
|
||||
maxFiles: 32,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { createParamDecorator } from '@nestjs/common';
|
||||
import { User } from '@prisma/client';
|
||||
import { User, UserSession } from '@prisma/client';
|
||||
|
||||
import { getRequestResponseFromContext } from '../../fundamentals';
|
||||
|
||||
@@ -53,3 +53,5 @@ export interface CurrentUser
|
||||
hasPassword: boolean | null;
|
||||
emailVerified: boolean;
|
||||
}
|
||||
|
||||
export { type UserSession };
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import type {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
FactoryProvider,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, SetMetadata, UseGuards } from '@nestjs/common';
|
||||
import { ModuleRef, Reflector } from '@nestjs/core';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import {
|
||||
AuthenticationRequired,
|
||||
Config,
|
||||
getRequestResponseFromContext,
|
||||
mapAnyError,
|
||||
parseCookies,
|
||||
} from '../../fundamentals';
|
||||
import { WEBSOCKET_OPTIONS } from '../../fundamentals/websocket';
|
||||
import { CurrentUser, UserSession } from './current-user';
|
||||
import { AuthService, parseAuthUserSeqNum } from './service';
|
||||
|
||||
function extractTokenFromHeader(authorization: string) {
|
||||
@@ -38,37 +45,9 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const { req, res } = getRequestResponseFromContext(context);
|
||||
|
||||
// check cookie
|
||||
let sessionToken: string | undefined =
|
||||
req.cookies[AuthService.sessionCookieName];
|
||||
|
||||
if (!sessionToken && req.headers.authorization) {
|
||||
sessionToken = extractTokenFromHeader(req.headers.authorization);
|
||||
}
|
||||
|
||||
if (sessionToken) {
|
||||
const userSeq = parseAuthUserSeqNum(
|
||||
req.headers[AuthService.authUserSeqHeaderName]
|
||||
);
|
||||
|
||||
const { user, expiresAt } = await this.auth.getUser(
|
||||
sessionToken,
|
||||
userSeq
|
||||
);
|
||||
if (res && user && expiresAt) {
|
||||
await this.auth.refreshUserSessionIfNeeded(
|
||||
req,
|
||||
res,
|
||||
sessionToken,
|
||||
user.id,
|
||||
expiresAt
|
||||
);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
req.sid = sessionToken;
|
||||
req.user = user;
|
||||
}
|
||||
const userSession = await this.signIn(req);
|
||||
if (res && userSession && userSession.session.expiresAt) {
|
||||
await this.auth.refreshUserSessionIfNeeded(req, res, userSession.session);
|
||||
}
|
||||
|
||||
// api is public
|
||||
@@ -84,9 +63,44 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
if (!req.user) {
|
||||
throw new AuthenticationRequired();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async signIn(
|
||||
req: Request
|
||||
): Promise<{ user: CurrentUser; session: UserSession } | null> {
|
||||
if (req.user && req.session) {
|
||||
return {
|
||||
user: req.user,
|
||||
session: req.session,
|
||||
};
|
||||
}
|
||||
|
||||
parseCookies(req);
|
||||
let sessionToken: string | undefined =
|
||||
req.cookies[AuthService.sessionCookieName];
|
||||
|
||||
if (!sessionToken && req.headers.authorization) {
|
||||
sessionToken = extractTokenFromHeader(req.headers.authorization);
|
||||
}
|
||||
|
||||
if (sessionToken) {
|
||||
const userSeq = parseAuthUserSeqNum(
|
||||
req.headers[AuthService.authUserSeqHeaderName]
|
||||
);
|
||||
|
||||
const userSession = await this.auth.getUserSession(sessionToken, userSeq);
|
||||
|
||||
if (userSession) {
|
||||
req.session = userSession.session;
|
||||
req.user = userSession.user;
|
||||
}
|
||||
|
||||
return userSession;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,3 +125,35 @@ export const Auth = () => {
|
||||
|
||||
// api is public accessible
|
||||
export const Public = () => SetMetadata(PUBLIC_ENTRYPOINT_SYMBOL, true);
|
||||
|
||||
export const AuthWebsocketOptionsProvider: FactoryProvider = {
|
||||
provide: WEBSOCKET_OPTIONS,
|
||||
useFactory: (config: Config, guard: AuthGuard) => {
|
||||
return {
|
||||
...config.websocket,
|
||||
allowRequest: async (
|
||||
req: any,
|
||||
pass: (err: string | null | undefined, success: boolean) => void
|
||||
) => {
|
||||
if (!config.websocket.requireAuthentication) {
|
||||
return pass(null, true);
|
||||
}
|
||||
|
||||
try {
|
||||
const authentication = await guard.signIn(req);
|
||||
|
||||
if (authentication) {
|
||||
return pass(null, true);
|
||||
} else {
|
||||
return pass('unauthenticated', false);
|
||||
}
|
||||
} catch (e) {
|
||||
const error = mapAnyError(e);
|
||||
error.log('Websocket');
|
||||
return pass('unauthenticated', false);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
inject: [Config, AuthGuard],
|
||||
};
|
||||
|
||||
@@ -6,15 +6,21 @@ import { FeatureModule } from '../features';
|
||||
import { QuotaModule } from '../quota';
|
||||
import { UserModule } from '../user';
|
||||
import { AuthController } from './controller';
|
||||
import { AuthGuard } from './guard';
|
||||
import { AuthGuard, AuthWebsocketOptionsProvider } from './guard';
|
||||
import { AuthResolver } from './resolver';
|
||||
import { AuthService } from './service';
|
||||
import { TokenService, TokenType } from './token';
|
||||
|
||||
@Module({
|
||||
imports: [FeatureModule, UserModule, QuotaModule],
|
||||
providers: [AuthService, AuthResolver, TokenService, AuthGuard],
|
||||
exports: [AuthService, AuthGuard],
|
||||
providers: [
|
||||
AuthService,
|
||||
AuthResolver,
|
||||
TokenService,
|
||||
AuthGuard,
|
||||
AuthWebsocketOptionsProvider,
|
||||
],
|
||||
exports: [AuthService, AuthGuard, AuthWebsocketOptionsProvider],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Throttle,
|
||||
URLHelper,
|
||||
} from '../../fundamentals';
|
||||
import { Admin } from '../common';
|
||||
import { UserService } from '../user';
|
||||
import { UserType } from '../user/types';
|
||||
import { validators } from '../utils/validators';
|
||||
@@ -291,4 +292,19 @@ export class AuthResolver {
|
||||
|
||||
return emailVerifiedAt !== null;
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@Mutation(() => String, {
|
||||
description: 'Create change password url',
|
||||
})
|
||||
async createChangePasswordUrl(
|
||||
@Args('userId') userId: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
): Promise<string> {
|
||||
const token = await this.token.createToken(
|
||||
TokenType.ChangePassword,
|
||||
userId
|
||||
);
|
||||
return this.url.link(callbackUrl, { token });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import type { User } from '@prisma/client';
|
||||
import type { User, UserSession } from '@prisma/client';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import type { CookieOptions, Request, Response } from 'express';
|
||||
import { assign, omit } from 'lodash-es';
|
||||
import { assign, pick } from 'lodash-es';
|
||||
|
||||
import { Config, EmailAlreadyUsed, MailService } from '../../fundamentals';
|
||||
import { FeatureManagementService } from '../features/management';
|
||||
@@ -41,13 +41,11 @@ export function sessionUser(
|
||||
'id' | 'email' | 'avatarUrl' | 'name' | 'emailVerifiedAt'
|
||||
> & { password?: string | null }
|
||||
): CurrentUser {
|
||||
return assign(
|
||||
omit(user, 'password', 'registered', 'emailVerifiedAt', 'createdAt'),
|
||||
{
|
||||
hasPassword: user.password !== null,
|
||||
emailVerified: user.emailVerifiedAt !== null,
|
||||
}
|
||||
);
|
||||
// use pick to avoid unexpected fields
|
||||
return assign(pick(user, 'id', 'email', 'avatarUrl', 'name'), {
|
||||
hasPassword: user.password !== null,
|
||||
emailVerified: user.emailVerifiedAt !== null,
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -121,27 +119,27 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
return sessionUser(user);
|
||||
}
|
||||
|
||||
async getUser(
|
||||
async getUserSession(
|
||||
token: string,
|
||||
seq = 0
|
||||
): Promise<{ user: CurrentUser | null; expiresAt: Date | null }> {
|
||||
): Promise<{ user: CurrentUser; session: UserSession } | null> {
|
||||
const session = await this.getSession(token);
|
||||
|
||||
// no such session
|
||||
if (!session) {
|
||||
return { user: null, expiresAt: null };
|
||||
return null;
|
||||
}
|
||||
|
||||
const userSession = session.userSessions.at(seq);
|
||||
|
||||
// no such user session
|
||||
if (!userSession) {
|
||||
return { user: null, expiresAt: null };
|
||||
return null;
|
||||
}
|
||||
|
||||
// user session expired
|
||||
if (userSession.expiresAt && userSession.expiresAt <= new Date()) {
|
||||
return { user: null, expiresAt: null };
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await this.db.user.findUnique({
|
||||
@@ -149,10 +147,10 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return { user: null, expiresAt: null };
|
||||
return null;
|
||||
}
|
||||
|
||||
return { user: sessionUser(user), expiresAt: userSession.expiresAt };
|
||||
return { user: sessionUser(user), session: userSession };
|
||||
}
|
||||
|
||||
async getUserList(token: string) {
|
||||
@@ -251,12 +249,13 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
async refreshUserSessionIfNeeded(
|
||||
_req: Request,
|
||||
res: Response,
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
expiresAt: Date,
|
||||
session: UserSession,
|
||||
ttr = this.config.auth.session.ttr
|
||||
): Promise<boolean> {
|
||||
if (expiresAt && expiresAt.getTime() - Date.now() > ttr * 1000) {
|
||||
if (
|
||||
session.expiresAt &&
|
||||
session.expiresAt.getTime() - Date.now() > ttr * 1000
|
||||
) {
|
||||
// no need to refresh
|
||||
return false;
|
||||
}
|
||||
@@ -267,17 +266,14 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
|
||||
await this.db.userSession.update({
|
||||
where: {
|
||||
sessionId_userId: {
|
||||
sessionId,
|
||||
userId,
|
||||
},
|
||||
id: session.id,
|
||||
},
|
||||
data: {
|
||||
expiresAt: newExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
res.cookie(AuthService.sessionCookieName, sessionId, {
|
||||
res.cookie(AuthService.sessionCookieName, session.sessionId, {
|
||||
expires: newExpiresAt,
|
||||
...this.cookieOptions,
|
||||
});
|
||||
|
||||
@@ -16,5 +16,5 @@ import {
|
||||
],
|
||||
})
|
||||
export class ServerConfigModule {}
|
||||
export { ADD_ENABLED_FEATURES, ServerConfigType } from './resolver';
|
||||
export { ADD_ENABLED_FEATURES } from './server-feature';
|
||||
export { ServerFeature } from './types';
|
||||
|
||||
@@ -12,24 +12,14 @@ import {
|
||||
import { PrismaClient, RuntimeConfig, RuntimeConfigType } from '@prisma/client';
|
||||
import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars';
|
||||
|
||||
import { Config, DeploymentType, URLHelper } from '../../fundamentals';
|
||||
import { Config, URLHelper } from '../../fundamentals';
|
||||
import { Public } from '../auth';
|
||||
import { Admin } from '../common';
|
||||
import { FeatureType } from '../features';
|
||||
import { AvailableUserFeatureConfig } from '../features/resolver';
|
||||
import { ServerFlags } from './config';
|
||||
import { ServerFeature } from './types';
|
||||
|
||||
const ENABLED_FEATURES: Set<ServerFeature> = new Set();
|
||||
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
|
||||
ENABLED_FEATURES.add(feature);
|
||||
}
|
||||
|
||||
registerEnumType(ServerFeature, {
|
||||
name: 'ServerFeature',
|
||||
});
|
||||
|
||||
registerEnumType(DeploymentType, {
|
||||
name: 'ServerDeploymentType',
|
||||
});
|
||||
import { ENABLED_FEATURES } from './server-feature';
|
||||
import { ServerConfigType } from './types';
|
||||
|
||||
@ObjectType()
|
||||
export class PasswordLimitsType {
|
||||
@@ -45,36 +35,6 @@ export class CredentialsRequirementType {
|
||||
password!: PasswordLimitsType;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class ServerConfigType {
|
||||
@Field({
|
||||
description:
|
||||
'server identical name could be shown as badge on user interface',
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@Field({ description: 'server version' })
|
||||
version!: string;
|
||||
|
||||
@Field({ description: 'server base url' })
|
||||
baseUrl!: string;
|
||||
|
||||
@Field(() => DeploymentType, { description: 'server type' })
|
||||
type!: DeploymentType;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Field({ description: 'server flavor', deprecationReason: 'use `features`' })
|
||||
flavor!: string;
|
||||
|
||||
@Field(() => [ServerFeature], { description: 'enabled server features' })
|
||||
features!: ServerFeature[];
|
||||
|
||||
@Field({ description: 'enable telemetry' })
|
||||
enableTelemetry!: boolean;
|
||||
}
|
||||
|
||||
registerEnumType(RuntimeConfigType, {
|
||||
name: 'RuntimeConfigType',
|
||||
});
|
||||
@@ -175,6 +135,20 @@ export class ServerConfigResolver {
|
||||
}
|
||||
}
|
||||
|
||||
@Resolver(() => ServerConfigType)
|
||||
export class ServerFeatureConfigResolver extends AvailableUserFeatureConfig {
|
||||
constructor(config: Config) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
@ResolveField(() => [FeatureType], {
|
||||
description: 'Features for user that can be configured',
|
||||
})
|
||||
override availableUserFeatures() {
|
||||
return super.availableUserFeatures();
|
||||
}
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class ServerServiceConfig {
|
||||
@Field()
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ServerFeature } from './types';
|
||||
|
||||
export const ENABLED_FEATURES: Set<ServerFeature> = new Set();
|
||||
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
|
||||
ENABLED_FEATURES.add(feature);
|
||||
}
|
||||
export { ServerFeature };
|
||||
@@ -1,5 +1,47 @@
|
||||
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { DeploymentType } from '../../fundamentals';
|
||||
|
||||
export enum ServerFeature {
|
||||
Copilot = 'copilot',
|
||||
Payment = 'payment',
|
||||
OAuth = 'oauth',
|
||||
}
|
||||
|
||||
registerEnumType(ServerFeature, {
|
||||
name: 'ServerFeature',
|
||||
});
|
||||
|
||||
registerEnumType(DeploymentType, {
|
||||
name: 'ServerDeploymentType',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class ServerConfigType {
|
||||
@Field({
|
||||
description:
|
||||
'server identical name could be shown as badge on user interface',
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@Field({ description: 'server version' })
|
||||
version!: string;
|
||||
|
||||
@Field({ description: 'server base url' })
|
||||
baseUrl!: string;
|
||||
|
||||
@Field(() => DeploymentType, { description: 'server type' })
|
||||
type!: DeploymentType;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Field({ description: 'server flavor', deprecationReason: 'use `features`' })
|
||||
flavor!: string;
|
||||
|
||||
@Field(() => [ServerFeature], { description: 'enabled server features' })
|
||||
features!: ServerFeature[];
|
||||
|
||||
@Field({ description: 'enable telemetry' })
|
||||
enableTelemetry!: boolean;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { UserModule } from '../user';
|
||||
import { EarlyAccessType, FeatureManagementService } from './management';
|
||||
import { FeatureManagementResolver } from './resolver';
|
||||
import {
|
||||
AdminFeatureManagementResolver,
|
||||
FeatureManagementResolver,
|
||||
} from './resolver';
|
||||
import { FeatureService } from './service';
|
||||
|
||||
/**
|
||||
@@ -17,6 +20,7 @@ import { FeatureService } from './service';
|
||||
FeatureService,
|
||||
FeatureManagementService,
|
||||
FeatureManagementResolver,
|
||||
AdminFeatureManagementResolver,
|
||||
],
|
||||
exports: [FeatureService, FeatureManagementService],
|
||||
})
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import {
|
||||
Args,
|
||||
Context,
|
||||
Int,
|
||||
Mutation,
|
||||
Parent,
|
||||
Query,
|
||||
registerEnumType,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { difference } from 'lodash-es';
|
||||
|
||||
import { UserNotFound } from '../../fundamentals';
|
||||
import { sessionUser } from '../auth/service';
|
||||
import { Config } from '../../fundamentals';
|
||||
import { Admin } from '../common';
|
||||
import { UserService } from '../user/service';
|
||||
import { UserType } from '../user/types';
|
||||
import { EarlyAccessType, FeatureManagementService } from './management';
|
||||
import { FeatureService } from './service';
|
||||
import { FeatureType } from './types';
|
||||
|
||||
registerEnumType(EarlyAccessType, {
|
||||
@@ -24,10 +21,7 @@ registerEnumType(EarlyAccessType, {
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class FeatureManagementResolver {
|
||||
constructor(
|
||||
private readonly users: UserService,
|
||||
private readonly feature: FeatureManagementService
|
||||
) {}
|
||||
constructor(private readonly feature: FeatureManagementService) {}
|
||||
|
||||
@ResolveField(() => [FeatureType], {
|
||||
name: 'features',
|
||||
@@ -36,58 +30,48 @@ export class FeatureManagementResolver {
|
||||
async userFeatures(@Parent() user: UserType) {
|
||||
return this.feature.getActivatedUserFeatures(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@Mutation(() => Int)
|
||||
async addToEarlyAccess(
|
||||
@Args('email') email: string,
|
||||
@Args({ name: 'type', type: () => EarlyAccessType }) type: EarlyAccessType
|
||||
): Promise<number> {
|
||||
const user = await this.users.findUserByEmail(email);
|
||||
if (user) {
|
||||
return this.feature.addEarlyAccess(user.id, type);
|
||||
} else {
|
||||
const user = await this.users.createUser({
|
||||
email,
|
||||
registered: false,
|
||||
});
|
||||
return this.feature.addEarlyAccess(user.id, type);
|
||||
}
|
||||
}
|
||||
export class AvailableUserFeatureConfig {
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
@Admin()
|
||||
@Mutation(() => Int)
|
||||
async removeEarlyAccess(@Args('email') email: string): Promise<number> {
|
||||
const user = await this.users.findUserByEmail(email);
|
||||
if (!user) {
|
||||
throw new UserNotFound();
|
||||
}
|
||||
return this.feature.removeEarlyAccess(user.id);
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@Query(() => [UserType])
|
||||
async earlyAccessUsers(
|
||||
@Context() ctx: { isAdminQuery: boolean }
|
||||
): Promise<UserType[]> {
|
||||
// allow query other user's subscription
|
||||
ctx.isAdminQuery = true;
|
||||
return this.feature.listEarlyAccess().then(users => {
|
||||
return users.map(sessionUser);
|
||||
});
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@Mutation(() => Boolean)
|
||||
async addAdminister(@Args('email') email: string): Promise<boolean> {
|
||||
const user = await this.users.findUserByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
throw new UserNotFound();
|
||||
}
|
||||
|
||||
await this.feature.addAdmin(user.id);
|
||||
|
||||
return true;
|
||||
async availableUserFeatures() {
|
||||
return this.config.isSelfhosted
|
||||
? [FeatureType.Admin]
|
||||
: [FeatureType.EarlyAccess, FeatureType.AIEarlyAccess, FeatureType.Admin];
|
||||
}
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@Resolver(() => Boolean)
|
||||
export class AdminFeatureManagementResolver extends AvailableUserFeatureConfig {
|
||||
constructor(
|
||||
config: Config,
|
||||
private readonly feature: FeatureService
|
||||
) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
@Mutation(() => [FeatureType], {
|
||||
description: 'update user enabled feature',
|
||||
})
|
||||
async updateUserFeatures(
|
||||
@Args('id') id: string,
|
||||
@Args({ name: 'features', type: () => [FeatureType] })
|
||||
features: FeatureType[]
|
||||
) {
|
||||
const configurableFeatures = await this.availableUserFeatures();
|
||||
|
||||
const removed = difference(configurableFeatures, features);
|
||||
await Promise.all(
|
||||
features.map(feature =>
|
||||
this.feature.addUserFeature(id, feature, 'admin panel')
|
||||
)
|
||||
);
|
||||
await Promise.all(
|
||||
removed.map(feature => this.feature.removeUserFeature(id, feature))
|
||||
);
|
||||
|
||||
return features;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { CannotDeleteAllAdminAccount } from '../../fundamentals';
|
||||
import { WorkspaceType } from '../workspaces/types';
|
||||
import { FeatureConfigType, getFeature } from './feature';
|
||||
import { FeatureKind, FeatureType } from './types';
|
||||
@@ -81,6 +82,9 @@ export class FeatureService {
|
||||
}
|
||||
|
||||
async removeUserFeature(userId: string, feature: FeatureType) {
|
||||
if (feature === FeatureType.Admin) {
|
||||
await this.ensureNotLastAdmin(userId);
|
||||
}
|
||||
return this.prisma.userFeature
|
||||
.updateMany({
|
||||
where: {
|
||||
@@ -98,6 +102,20 @@ export class FeatureService {
|
||||
.then(r => r.count);
|
||||
}
|
||||
|
||||
async ensureNotLastAdmin(userId: string) {
|
||||
const count = await this.prisma.userFeature.count({
|
||||
where: {
|
||||
userId: { not: userId },
|
||||
feature: { feature: FeatureType.Admin, type: FeatureKind.Feature },
|
||||
activated: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
throw new CannotDeleteAllAdminAccount();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* get user's features, will included inactivated features
|
||||
* @param userId user id
|
||||
|
||||
@@ -50,12 +50,7 @@ function Awareness(workspaceId: string): `${string}:awareness` {
|
||||
return `${workspaceId}:awareness`;
|
||||
}
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: !AFFiNE.node.prod,
|
||||
transports: ['websocket'],
|
||||
// see: https://socket.io/docs/v4/server-options/#maxhttpbuffersize
|
||||
maxHttpBufferSize: 1e8, // 100 MB
|
||||
})
|
||||
@WebSocketGateway()
|
||||
export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
protected logger = new Logger(EventsGateway.name);
|
||||
private connectionCount = 0;
|
||||
|
||||
@@ -12,7 +12,12 @@ import { PrismaClient } from '@prisma/client';
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
import { isNil, omitBy } from 'lodash-es';
|
||||
|
||||
import { type FileUpload, Throttle, UserNotFound } from '../../fundamentals';
|
||||
import {
|
||||
CannotDeleteOwnAccount,
|
||||
type FileUpload,
|
||||
Throttle,
|
||||
UserNotFound,
|
||||
} from '../../fundamentals';
|
||||
import { CurrentUser } from '../auth/current-user';
|
||||
import { Public } from '../auth/guard';
|
||||
import { sessionUser } from '../auth/service';
|
||||
@@ -22,6 +27,7 @@ import { validators } from '../utils/validators';
|
||||
import { UserService } from './service';
|
||||
import {
|
||||
DeleteAccount,
|
||||
ManageUserInput,
|
||||
RemoveAvatar,
|
||||
UpdateUserInput,
|
||||
UserOrLimitedUser,
|
||||
@@ -161,9 +167,6 @@ class CreateUserInput {
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
name!: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
password!: string | null;
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@@ -174,6 +177,13 @@ export class UserManagementResolver {
|
||||
private readonly user: UserService
|
||||
) {}
|
||||
|
||||
@Query(() => Int, {
|
||||
description: 'Get users count',
|
||||
})
|
||||
async usersCount(): Promise<number> {
|
||||
return this.db.user.count();
|
||||
}
|
||||
|
||||
@Query(() => [UserType], {
|
||||
description: 'List registered users',
|
||||
})
|
||||
@@ -208,6 +218,26 @@ export class UserManagementResolver {
|
||||
return sessionUser(user);
|
||||
}
|
||||
|
||||
@Query(() => UserType, {
|
||||
name: 'userByEmail',
|
||||
description: 'Get user by email for admin',
|
||||
nullable: true,
|
||||
})
|
||||
async getUserByEmail(@Args('email') email: string) {
|
||||
const user = await this.db.user.findUnique({
|
||||
select: { ...this.user.defaultUserSelect, password: true },
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sessionUser(user);
|
||||
}
|
||||
|
||||
@Mutation(() => UserType, {
|
||||
description: 'Create a new user',
|
||||
})
|
||||
@@ -216,7 +246,6 @@ export class UserManagementResolver {
|
||||
) {
|
||||
const { id } = await this.user.createUser({
|
||||
email: input.email,
|
||||
password: input.password,
|
||||
registered: true,
|
||||
});
|
||||
|
||||
@@ -227,8 +256,42 @@ export class UserManagementResolver {
|
||||
@Mutation(() => DeleteAccount, {
|
||||
description: 'Delete a user account',
|
||||
})
|
||||
async deleteUser(@Args('id') id: string): Promise<DeleteAccount> {
|
||||
async deleteUser(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('id') id: string
|
||||
): Promise<DeleteAccount> {
|
||||
if (user.id === id) {
|
||||
throw new CannotDeleteOwnAccount();
|
||||
}
|
||||
await this.user.deleteUser(id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Mutation(() => UserType, {
|
||||
description: 'Update a user',
|
||||
})
|
||||
async updateUser(
|
||||
@Args('id') id: string,
|
||||
@Args('input') input: ManageUserInput
|
||||
): Promise<UserType> {
|
||||
const user = await this.db.user.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UserNotFound();
|
||||
}
|
||||
|
||||
input = omitBy(input, isNil);
|
||||
if (Object.keys(input).length === 0) {
|
||||
return sessionUser(user);
|
||||
}
|
||||
|
||||
return sessionUser(
|
||||
await this.user.updateUser(user.id, {
|
||||
email: input.email,
|
||||
name: input.name,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,9 +194,7 @@ export class UserService {
|
||||
|
||||
async updateUser(
|
||||
id: string,
|
||||
data: Omit<Prisma.UserUpdateInput, 'password'> & {
|
||||
password?: string | null;
|
||||
},
|
||||
data: Omit<Partial<Prisma.UserCreateInput>, 'id'>,
|
||||
select: Prisma.UserSelect = this.defaultUserSelect
|
||||
) {
|
||||
if (data.password) {
|
||||
@@ -211,6 +209,23 @@ export class UserService {
|
||||
|
||||
data.password = await this.crypto.encryptPassword(data.password);
|
||||
}
|
||||
|
||||
if (data.email) {
|
||||
validators.assertValidEmail(data.email);
|
||||
const emailTaken = await this.prisma.user.count({
|
||||
where: {
|
||||
email: data.email,
|
||||
id: {
|
||||
not: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (emailTaken) {
|
||||
throw new EmailAlreadyUsed();
|
||||
}
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.update({ where: { id }, data, select });
|
||||
|
||||
this.emitter.emit('user.updated', user);
|
||||
|
||||
@@ -83,6 +83,15 @@ export class UpdateUserInput implements Partial<User> {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class ManageUserInput {
|
||||
@Field({ description: 'User email', nullable: true })
|
||||
email?: string;
|
||||
|
||||
@Field({ description: 'User name', nullable: true })
|
||||
name?: string;
|
||||
}
|
||||
|
||||
declare module '../../fundamentals/event/def' {
|
||||
interface UserEvents {
|
||||
admin: {
|
||||
|
||||
@@ -498,4 +498,12 @@ export const USER_FRIENDLY_ERRORS = {
|
||||
type: 'internal_server_error',
|
||||
message: 'Mailer service is not configured.',
|
||||
},
|
||||
cannot_delete_all_admin_account: {
|
||||
type: 'action_forbidden',
|
||||
message: 'Cannot delete all admin accounts.',
|
||||
},
|
||||
cannot_delete_own_account: {
|
||||
type: 'action_forbidden',
|
||||
message: 'Cannot delete own account.',
|
||||
},
|
||||
} satisfies Record<string, UserFriendlyErrorOptions>;
|
||||
|
||||
@@ -487,6 +487,18 @@ export class MailerServiceIsNotConfigured extends UserFriendlyError {
|
||||
super('internal_server_error', 'mailer_service_is_not_configured', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class CannotDeleteAllAdminAccount extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'cannot_delete_all_admin_account', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class CannotDeleteOwnAccount extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'cannot_delete_own_account', message);
|
||||
}
|
||||
}
|
||||
export enum ErrorNames {
|
||||
INTERNAL_SERVER_ERROR,
|
||||
TOO_MANY_REQUEST,
|
||||
@@ -551,7 +563,9 @@ export enum ErrorNames {
|
||||
COPILOT_QUOTA_EXCEEDED,
|
||||
RUNTIME_CONFIG_NOT_FOUND,
|
||||
INVALID_RUNTIME_CONFIG_TYPE,
|
||||
MAILER_SERVICE_IS_NOT_CONFIGURED
|
||||
MAILER_SERVICE_IS_NOT_CONFIGURED,
|
||||
CANNOT_DELETE_ALL_ADMIN_ACCOUNT,
|
||||
CANNOT_DELETE_OWN_ACCOUNT
|
||||
}
|
||||
registerEnumType(ErrorNames, {
|
||||
name: 'ErrorNames'
|
||||
|
||||
@@ -36,5 +36,6 @@ export {
|
||||
getRequestFromHost,
|
||||
getRequestResponseFromContext,
|
||||
getRequestResponseFromHost,
|
||||
parseCookies,
|
||||
} from './utils/request';
|
||||
export type * from './utils/types';
|
||||
|
||||
@@ -148,7 +148,7 @@ export const emailTemplate = ({
|
||||
</a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://discord.gg/Arn7TqJBvG" target="_blank"
|
||||
<a href="https://discord.gg/whd5mjYqVw" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Discord.png"
|
||||
alt="AFFiNE discord link"
|
||||
|
||||
@@ -2,8 +2,10 @@ import { ArgumentsHost, Catch, Logger } from '@nestjs/common';
|
||||
import { BaseExceptionFilter } from '@nestjs/core';
|
||||
import { GqlContextType } from '@nestjs/graphql';
|
||||
import { ThrottlerException } from '@nestjs/throttler';
|
||||
import { BaseWsExceptionFilter } from '@nestjs/websockets';
|
||||
import { Response } from 'express';
|
||||
import { of } from 'rxjs';
|
||||
import { Socket } from 'socket.io';
|
||||
|
||||
import {
|
||||
InternalServerError,
|
||||
@@ -44,6 +46,20 @@ export class GlobalExceptionFilter extends BaseExceptionFilter {
|
||||
}
|
||||
}
|
||||
|
||||
export class GlobalWsExceptionFilter extends BaseWsExceptionFilter {
|
||||
// @ts-expect-error satisfies the override
|
||||
override handleError(client: Socket, exception: any): void {
|
||||
const error = mapAnyError(exception);
|
||||
error.log('Websocket');
|
||||
metrics.socketio
|
||||
.counter('unhandled_error')
|
||||
.add(1, { status: error.status });
|
||||
client.emit('error', {
|
||||
error: toWebsocketError(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only exists for websocket error body backward compatibility
|
||||
*
|
||||
|
||||
@@ -57,7 +57,7 @@ export class CloudThrottlerGuard extends ThrottlerGuard {
|
||||
override getTracker(req: Request): Promise<string> {
|
||||
return Promise.resolve(
|
||||
// ↓ prefer session id if available
|
||||
`throttler:${req.sid ?? req.get('CF-Connecting-IP') ?? req.get('CF-ray') ?? req.ip}`
|
||||
`throttler:${req.session?.sessionId ?? req.get('CF-Connecting-IP') ?? req.get('CF-ray') ?? req.ip}`
|
||||
// ^ throttler prefix make the key in store recognizable
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,3 +66,29 @@ export function getRequestFromHost(host: ArgumentsHost) {
|
||||
export function getRequestResponseFromContext(ctx: ExecutionContext) {
|
||||
return getRequestResponseFromHost(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* simple patch for request not protected by `cookie-parser`
|
||||
* only take effect if `req.cookies` is not defined
|
||||
*/
|
||||
export function parseCookies(req: Request) {
|
||||
if (req.cookies) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cookieStr = req?.headers?.cookie ?? '';
|
||||
req.cookies = cookieStr.split(';').reduce(
|
||||
(cookies, cookie) => {
|
||||
const [key, val] = cookie.split('=');
|
||||
|
||||
if (key) {
|
||||
cookies[decodeURIComponent(key.trim())] = val
|
||||
? decodeURIComponent(val.trim())
|
||||
: val;
|
||||
}
|
||||
|
||||
return cookies;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
}
|
||||
|
||||
20
packages/backend/server/src/fundamentals/websocket/config.ts
Normal file
20
packages/backend/server/src/fundamentals/websocket/config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { GatewayMetadata } from '@nestjs/websockets';
|
||||
|
||||
import { defineStartupConfig, ModuleConfig } from '../config';
|
||||
|
||||
declare module '../config' {
|
||||
interface AppConfig {
|
||||
websocket: ModuleConfig<
|
||||
GatewayMetadata & {
|
||||
requireAuthentication?: boolean;
|
||||
}
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
defineStartupConfig('websocket', {
|
||||
// see: https://socket.io/docs/v4/server-options/#maxhttpbuffersize
|
||||
transports: ['websocket'],
|
||||
maxHttpBufferSize: 1e8, // 100 MB
|
||||
requireAuthentication: true,
|
||||
});
|
||||
@@ -1,17 +1,46 @@
|
||||
import { Module, Provider } from '@nestjs/common';
|
||||
import './config';
|
||||
|
||||
import {
|
||||
FactoryProvider,
|
||||
INestApplicationContext,
|
||||
Module,
|
||||
Provider,
|
||||
} from '@nestjs/common';
|
||||
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||
import { Server } from 'socket.io';
|
||||
|
||||
import { Config } from '../config';
|
||||
|
||||
export const SocketIoAdapterImpl = Symbol('SocketIoAdapterImpl');
|
||||
|
||||
export class SocketIoAdapter extends IoAdapter {}
|
||||
export class SocketIoAdapter extends IoAdapter {
|
||||
constructor(protected readonly app: INestApplicationContext) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
override createIOServer(port: number, options?: any): Server {
|
||||
const config = this.app.get(WEBSOCKET_OPTIONS);
|
||||
return super.createIOServer(port, { ...config, ...options });
|
||||
}
|
||||
}
|
||||
|
||||
const SocketIoAdapterImplProvider: Provider = {
|
||||
provide: SocketIoAdapterImpl,
|
||||
useValue: SocketIoAdapter,
|
||||
};
|
||||
|
||||
export const WEBSOCKET_OPTIONS = Symbol('WEBSOCKET_OPTIONS');
|
||||
|
||||
export const websocketOptionsProvider: FactoryProvider = {
|
||||
provide: WEBSOCKET_OPTIONS,
|
||||
useFactory: (config: Config) => {
|
||||
return config.websocket;
|
||||
},
|
||||
inject: [Config],
|
||||
};
|
||||
|
||||
@Module({
|
||||
providers: [SocketIoAdapterImplProvider],
|
||||
exports: [SocketIoAdapterImplProvider],
|
||||
providers: [SocketIoAdapterImplProvider, websocketOptionsProvider],
|
||||
exports: [SocketIoAdapterImplProvider, websocketOptionsProvider],
|
||||
})
|
||||
export class WebSocketModule {}
|
||||
|
||||
2
packages/backend/server/src/global.d.ts
vendored
2
packages/backend/server/src/global.d.ts
vendored
@@ -1,7 +1,7 @@
|
||||
declare namespace Express {
|
||||
interface Request {
|
||||
user?: import('./core/auth/current-user').CurrentUser;
|
||||
sid?: string;
|
||||
session?: import('./core/auth/current-user').UserSession;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -233,6 +233,7 @@ export class OpenAIProvider
|
||||
options: CopilotChatOptions = {}
|
||||
): AsyncIterable<string> {
|
||||
this.checkParams({ messages, model, options });
|
||||
|
||||
try {
|
||||
const result = await this.instance.chat.completions.create(
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
Float,
|
||||
ID,
|
||||
InputType,
|
||||
Mutation,
|
||||
@@ -205,16 +206,16 @@ class CopilotPromptConfigType {
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
jsonMode!: boolean | null;
|
||||
|
||||
@Field(() => Number, { nullable: true })
|
||||
@Field(() => Float, { nullable: true })
|
||||
frequencyPenalty!: number | null;
|
||||
|
||||
@Field(() => Number, { nullable: true })
|
||||
@Field(() => Float, { nullable: true })
|
||||
presencePenalty!: number | null;
|
||||
|
||||
@Field(() => Number, { nullable: true })
|
||||
@Field(() => Float, { nullable: true })
|
||||
temperature!: number | null;
|
||||
|
||||
@Field(() => Number, { nullable: true })
|
||||
@Field(() => Float, { nullable: true })
|
||||
topP!: number | null;
|
||||
}
|
||||
|
||||
@@ -238,8 +239,8 @@ class CopilotPromptType {
|
||||
@Field(() => String)
|
||||
name!: string;
|
||||
|
||||
@Field(() => AvailableModels)
|
||||
model!: AvailableModels;
|
||||
@Field(() => String)
|
||||
model!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
action!: string | null;
|
||||
|
||||
@@ -283,7 +283,13 @@ export class ChatSessionService {
|
||||
docId: true,
|
||||
parentSessionId: true,
|
||||
messages: {
|
||||
select: { id: true, role: true, content: true, createdAt: true },
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
content: true,
|
||||
attachments: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
promptName: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { registerEnumType, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { ServerConfigType } from '../../core/config';
|
||||
import { ServerConfigType } from '../../core/config/types';
|
||||
import { OAuthProviderName } from './config';
|
||||
import { OAuthProviderFactory } from './register';
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export function createSockerIoAdapterImpl(
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
const server = super.createIOServer(port, options) as Server;
|
||||
const server = super.createIOServer(port, options);
|
||||
server.adapter(createAdapter(pubClient, subClient));
|
||||
return server;
|
||||
}
|
||||
|
||||
@@ -61,19 +61,19 @@ enum CopilotModels {
|
||||
}
|
||||
|
||||
input CopilotPromptConfigInput {
|
||||
frequencyPenalty: Int
|
||||
frequencyPenalty: Float
|
||||
jsonMode: Boolean
|
||||
presencePenalty: Int
|
||||
temperature: Int
|
||||
topP: Int
|
||||
presencePenalty: Float
|
||||
temperature: Float
|
||||
topP: Float
|
||||
}
|
||||
|
||||
type CopilotPromptConfigType {
|
||||
frequencyPenalty: Int
|
||||
frequencyPenalty: Float
|
||||
jsonMode: Boolean
|
||||
presencePenalty: Int
|
||||
temperature: Int
|
||||
topP: Int
|
||||
presencePenalty: Float
|
||||
temperature: Float
|
||||
topP: Float
|
||||
}
|
||||
|
||||
input CopilotPromptMessageInput {
|
||||
@@ -102,7 +102,7 @@ type CopilotPromptType {
|
||||
action: String
|
||||
config: CopilotPromptConfigType
|
||||
messages: [CopilotPromptMessageType!]!
|
||||
model: CopilotModels!
|
||||
model: String!
|
||||
name: String!
|
||||
}
|
||||
|
||||
@@ -152,7 +152,6 @@ input CreateCopilotPromptInput {
|
||||
input CreateUserInput {
|
||||
email: String!
|
||||
name: String
|
||||
password: String
|
||||
}
|
||||
|
||||
type CredentialsRequirementType {
|
||||
@@ -196,11 +195,6 @@ type DocNotFoundDataType {
|
||||
workspaceId: String!
|
||||
}
|
||||
|
||||
enum EarlyAccessType {
|
||||
AI
|
||||
App
|
||||
}
|
||||
|
||||
union ErrorDataUnion = BlobNotFoundDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | InvalidHistoryTimestampDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MissingOauthQueryParameterDataType | NotInWorkspaceDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | VersionRejectedDataType | WorkspaceAccessDeniedDataType | WorkspaceNotFoundDataType | WorkspaceOwnerNotFoundDataType
|
||||
|
||||
enum ErrorNames {
|
||||
@@ -209,6 +203,8 @@ enum ErrorNames {
|
||||
AUTHENTICATION_REQUIRED
|
||||
BLOB_NOT_FOUND
|
||||
BLOB_QUOTA_EXCEEDED
|
||||
CANNOT_DELETE_ALL_ADMIN_ACCOUNT
|
||||
CANNOT_DELETE_OWN_ACCOUNT
|
||||
CANT_CHANGE_WORKSPACE_OWNER
|
||||
CANT_UPDATE_LIFETIME_SUBSCRIPTION
|
||||
COPILOT_ACTION_TAKEN
|
||||
@@ -396,14 +392,20 @@ input ListUserInput {
|
||||
skip: Int = 0
|
||||
}
|
||||
|
||||
input ManageUserInput {
|
||||
"""User email"""
|
||||
email: String
|
||||
|
||||
"""User name"""
|
||||
name: String
|
||||
}
|
||||
|
||||
type MissingOauthQueryParameterDataType {
|
||||
name: String!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean!
|
||||
addAdminister(email: String!): Boolean!
|
||||
addToEarlyAccess(email: String!, type: EarlyAccessType!): Int!
|
||||
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
|
||||
cancelSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription!
|
||||
changeEmail(email: String!, token: String!): UserType!
|
||||
@@ -412,6 +414,9 @@ type Mutation {
|
||||
"""Cleanup sessions"""
|
||||
cleanupCopilotSession(options: DeleteSessionInput!): [String!]!
|
||||
|
||||
"""Create change password url"""
|
||||
createChangePasswordUrl(callbackUrl: String!, userId: String!): String!
|
||||
|
||||
"""Create a subscription checkout link of stripe"""
|
||||
createCheckoutSession(input: CreateCheckoutSessionInput!): String!
|
||||
|
||||
@@ -448,7 +453,6 @@ type Mutation {
|
||||
|
||||
"""Remove user avatar"""
|
||||
removeAvatar: RemoveAvatar!
|
||||
removeEarlyAccess(email: String!): Int!
|
||||
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
|
||||
resumeSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription!
|
||||
revoke(userId: String!, workspaceId: String!): Boolean!
|
||||
@@ -474,6 +478,12 @@ type Mutation {
|
||||
updateRuntimeConfigs(updates: JSONObject!): [ServerRuntimeConfigType!]!
|
||||
updateSubscriptionRecurring(idempotencyKey: String!, plan: SubscriptionPlan = Pro, recurring: SubscriptionRecurring!): UserSubscription!
|
||||
|
||||
"""Update a user"""
|
||||
updateUser(id: String!, input: ManageUserInput!): UserType!
|
||||
|
||||
"""update user enabled feature"""
|
||||
updateUserFeatures(features: [FeatureType!]!, id: String!): [FeatureType!]!
|
||||
|
||||
"""Update workspace"""
|
||||
updateWorkspace(input: UpdateWorkspaceInput!): WorkspaceType!
|
||||
|
||||
@@ -517,7 +527,6 @@ type Query {
|
||||
|
||||
"""Get current user"""
|
||||
currentUser: UserType
|
||||
earlyAccessUsers: [UserType!]!
|
||||
error(name: ErrorNames!): ErrorDataUnion!
|
||||
|
||||
"""send workspace invitation"""
|
||||
@@ -544,12 +553,18 @@ type Query {
|
||||
"""Get user by email"""
|
||||
user(email: String!): UserOrLimitedUser
|
||||
|
||||
"""Get user by email for admin"""
|
||||
userByEmail(email: String!): UserType
|
||||
|
||||
"""Get user by id"""
|
||||
userById(id: String!): UserType!
|
||||
|
||||
"""List registered users"""
|
||||
users(filter: ListUserInput!): [UserType!]!
|
||||
|
||||
"""Get users count"""
|
||||
usersCount: Int!
|
||||
|
||||
"""Get workspace by id"""
|
||||
workspace(id: String!): WorkspaceType!
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ 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({ user: { id: '1' } });
|
||||
auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/public')
|
||||
@@ -100,7 +100,7 @@ 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({ user: { id: '1' } });
|
||||
auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/private')
|
||||
@@ -114,26 +114,26 @@ test('should be able to parse session cookie', async t => {
|
||||
const { app, auth } = t.context;
|
||||
|
||||
// @ts-expect-error mock
|
||||
auth.getUser.resolves({ user: { id: '1' } });
|
||||
auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/public')
|
||||
.set('cookie', `${AuthService.sessionCookieName}=1`)
|
||||
.expect(200);
|
||||
|
||||
t.deepEqual(auth.getUser.firstCall.args, ['1', 0]);
|
||||
t.deepEqual(auth.getUserSession.firstCall.args, ['1', 0]);
|
||||
});
|
||||
|
||||
test('should be able to parse bearer token', async t => {
|
||||
const { app, auth } = t.context;
|
||||
|
||||
// @ts-expect-error mock
|
||||
auth.getUser.resolves({ user: { id: '1' } });
|
||||
auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/public')
|
||||
.auth('1', { type: 'bearer' })
|
||||
.expect(200);
|
||||
|
||||
t.deepEqual(auth.getUser.firstCall.args, ['1', 0]);
|
||||
t.deepEqual(auth.getUserSession.firstCall.args, ['1', 0]);
|
||||
});
|
||||
|
||||
@@ -157,10 +157,10 @@ test('should be able to get user from session', async t => {
|
||||
|
||||
const session = await auth.createUserSession(u1);
|
||||
|
||||
const { user } = await auth.getUser(session.sessionId);
|
||||
const userSession = await auth.getUserSession(session.sessionId);
|
||||
|
||||
t.not(user, null);
|
||||
t.is(user!.id, u1.id);
|
||||
t.not(userSession, null);
|
||||
t.is(userSession!.user.id, u1.id);
|
||||
});
|
||||
|
||||
test('should be able to sign out session', async t => {
|
||||
@@ -203,19 +203,19 @@ test('should be able to signout multi accounts session', async t => {
|
||||
|
||||
t.not(signedOutSession, null);
|
||||
|
||||
const { user: signedU2 } = await auth.getUser(session.sessionId, 0);
|
||||
const { user: noUser } = await auth.getUser(session.sessionId, 1);
|
||||
const userSession1 = await auth.getUserSession(session.sessionId, 0);
|
||||
const userSession2 = await auth.getUserSession(session.sessionId, 1);
|
||||
|
||||
t.is(noUser, null);
|
||||
t.not(signedU2, null);
|
||||
t.is(userSession2, null);
|
||||
t.not(userSession1, null);
|
||||
|
||||
t.is(signedU2!.id, u2.id);
|
||||
t.is(userSession1!.user.id, u2.id);
|
||||
|
||||
// sign out user at seq(0)
|
||||
signedOutSession = await auth.signOut(session.sessionId);
|
||||
|
||||
t.is(signedOutSession, null);
|
||||
|
||||
const { user: noUser2 } = await auth.getUser(session.sessionId, 0);
|
||||
t.is(noUser2, null);
|
||||
const userSession3 = await auth.getUserSession(session.sessionId, 0);
|
||||
t.is(userSession3, null);
|
||||
});
|
||||
|
||||
@@ -246,14 +246,16 @@ test('should be able to manage chat session', async t => {
|
||||
|
||||
const s1 = (await session.get(sessionId))!;
|
||||
t.deepEqual(
|
||||
// @ts-expect-error
|
||||
s1.finish(params).map(({ id: _, createdAt: __, ...m }) => m),
|
||||
s1
|
||||
.finish(params)
|
||||
// @ts-expect-error
|
||||
.map(({ id: _, attachments: __, createdAt: ___, ...m }) => m),
|
||||
finalMessages,
|
||||
'should same as before message'
|
||||
);
|
||||
t.deepEqual(
|
||||
// @ts-expect-error
|
||||
s1.finish({}).map(({ id: _, createdAt: __, ...m }) => m),
|
||||
s1.finish({}).map(({ id: _, attachments: __, createdAt: ___, ...m }) => m),
|
||||
[
|
||||
{ content: 'hello ', params: {}, role: 'system' },
|
||||
{ content: 'hello', role: 'user' },
|
||||
@@ -325,7 +327,7 @@ test('should be able to fork chat session', async t => {
|
||||
|
||||
const finalMessages = s2
|
||||
.finish(params) // @ts-expect-error
|
||||
.map(({ id: _, createdAt: __, ...m }) => m);
|
||||
.map(({ id: _, attachments: __, createdAt: ___, ...m }) => m);
|
||||
t.deepEqual(
|
||||
finalMessages,
|
||||
[
|
||||
@@ -346,7 +348,7 @@ test('should be able to fork chat session', async t => {
|
||||
|
||||
const finalMessages = s2
|
||||
.finish(params) // @ts-expect-error
|
||||
.map(({ id: _, createdAt: __, ...m }) => m);
|
||||
.map(({ id: _, attachments: __, createdAt: ___, ...m }) => m);
|
||||
t.deepEqual(
|
||||
finalMessages,
|
||||
[
|
||||
@@ -364,7 +366,7 @@ test('should be able to fork chat session', async t => {
|
||||
|
||||
const finalMessages = s3
|
||||
.finish(params) // @ts-expect-error
|
||||
.map(({ id: _, createdAt: __, ...m }) => m);
|
||||
.map(({ id: _, attachments: __, createdAt: ___, ...m }) => m);
|
||||
t.deepEqual(
|
||||
finalMessages,
|
||||
[
|
||||
|
||||
@@ -341,8 +341,10 @@ test('should throw if oauth account already connected', async t => {
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-expect-error mock
|
||||
Sinon.stub(auth, 'getUser').resolves({ user: { id: 'u2-id' } });
|
||||
Sinon.stub(auth, 'getUserSession').resolves({
|
||||
user: { id: 'u2-id' },
|
||||
session: {},
|
||||
} as any);
|
||||
|
||||
mockOAuthProvider(app, 'u2@affine.pro');
|
||||
|
||||
@@ -363,8 +365,10 @@ test('should throw if oauth account already connected', async t => {
|
||||
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({ user: { id: u1.id } });
|
||||
Sinon.stub(auth, 'getUserSession').resolves({
|
||||
user: { id: u1.id },
|
||||
session: {},
|
||||
} as any);
|
||||
|
||||
mockOAuthProvider(app, u1.email);
|
||||
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"@types/debug": "^4.1.12",
|
||||
"vitest": "1.6.0"
|
||||
},
|
||||
"version": "0.15.0"
|
||||
"version": "0.16.0"
|
||||
}
|
||||
|
||||
6
packages/common/env/package.json
vendored
6
packages/common/env/package.json
vendored
@@ -3,8 +3,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@blocksuite/global": "0.16.0-canary-202408020231-96bb4ae",
|
||||
"@blocksuite/store": "0.16.0-canary-202408020231-96bb4ae",
|
||||
"@blocksuite/global": "0.17.0-canary-202408121434-ff85a54",
|
||||
"@blocksuite/store": "0.17.0-canary-202408121434-ff85a54",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"vitest": "1.6.0"
|
||||
@@ -26,5 +26,5 @@
|
||||
"lit": "^3.1.2",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"version": "0.15.0"
|
||||
"version": "0.16.0"
|
||||
}
|
||||
|
||||
8
packages/common/env/src/global.ts
vendored
8
packages/common/env/src/global.ts
vendored
@@ -31,13 +31,7 @@ export const runtimeFlagsSchema = z.object({
|
||||
enableExperimentalFeature: z.boolean(),
|
||||
enableInfoModal: z.boolean(),
|
||||
enableOrganize: z.boolean(),
|
||||
// show the new favorite, which exclusive to each user
|
||||
enableNewFavorite: z.boolean(),
|
||||
// show the old favorite
|
||||
enableOldFavorite: z.boolean(),
|
||||
// before 0.16, enableNewFavorite = false and enableOldFavorite = true
|
||||
// after 0.16, enableNewFavorite = true and enableOldFavorite = false
|
||||
// for debug purpose, we can enable both
|
||||
enableThemeEditor: z.boolean(),
|
||||
});
|
||||
|
||||
export type RuntimeConfig = z.infer<typeof runtimeFlagsSchema>;
|
||||
|
||||
12
packages/common/env/src/ua-helper.ts
vendored
12
packages/common/env/src/ua-helper.ts
vendored
@@ -1,5 +1,3 @@
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
|
||||
export class UaHelper {
|
||||
private readonly uaMap;
|
||||
public isLinux = false;
|
||||
@@ -12,8 +10,14 @@ export class UaHelper {
|
||||
public isIOS = false;
|
||||
|
||||
getChromeVersion = (): number => {
|
||||
const raw = this.navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
|
||||
assertExists(raw);
|
||||
let raw = this.navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
|
||||
if (!raw) {
|
||||
raw = this.navigator.userAgent.match(/(CriOS)\/([0-9]+)/);
|
||||
}
|
||||
if (!raw) {
|
||||
console.error('Cannot get chrome version');
|
||||
return 0;
|
||||
}
|
||||
return parseInt(raw[2], 10);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
"@affine/debug": "workspace:*",
|
||||
"@affine/env": "workspace:*",
|
||||
"@affine/templates": "workspace:*",
|
||||
"@blocksuite/blocks": "0.16.0-canary-202408020231-96bb4ae",
|
||||
"@blocksuite/global": "0.16.0-canary-202408020231-96bb4ae",
|
||||
"@blocksuite/presets": "0.16.0-canary-202408020231-96bb4ae",
|
||||
"@blocksuite/store": "0.16.0-canary-202408020231-96bb4ae",
|
||||
"@blocksuite/blocks": "0.17.0-canary-202408121434-ff85a54",
|
||||
"@blocksuite/global": "0.17.0-canary-202408121434-ff85a54",
|
||||
"@blocksuite/presets": "0.17.0-canary-202408121434-ff85a54",
|
||||
"@blocksuite/store": "0.17.0-canary-202408121434-ff85a54",
|
||||
"@datastructures-js/binary-search-tree": "^5.3.2",
|
||||
"foxact": "^0.2.33",
|
||||
"fuse.js": "^7.0.0",
|
||||
@@ -34,15 +34,15 @@
|
||||
"devDependencies": {
|
||||
"@affine-test/fixtures": "workspace:*",
|
||||
"@affine/templates": "workspace:*",
|
||||
"@blocksuite/block-std": "0.16.0-canary-202408020231-96bb4ae",
|
||||
"@blocksuite/presets": "0.16.0-canary-202408020231-96bb4ae",
|
||||
"@blocksuite/block-std": "0.17.0-canary-202408121434-ff85a54",
|
||||
"@blocksuite/presets": "0.17.0-canary-202408121434-ff85a54",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"async-call-rpc": "^6.4.0",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"react": "^18.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"vite": "^5.2.8",
|
||||
"vite-plugin-dts": "3.9.1",
|
||||
"vite-plugin-dts": "4.0.2",
|
||||
"vitest": "1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -73,5 +73,5 @@
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"version": "0.15.0"
|
||||
"version": "0.16.0"
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ export type AppSetting = {
|
||||
autoDownloadUpdate: boolean;
|
||||
enableMultiView: boolean;
|
||||
enableTelemetry: boolean;
|
||||
enableOutlineViewer: boolean;
|
||||
editorFlags: Partial<Omit<BlockSuiteFlags, 'readonly'>>;
|
||||
};
|
||||
export const windowFrameStyleOptions: AppSetting['windowFrameStyle'][] = [
|
||||
@@ -75,7 +74,6 @@ const appSettingBaseAtom = atomWithStorage<AppSetting>('affine-settings', {
|
||||
autoDownloadUpdate: true,
|
||||
enableTelemetry: true,
|
||||
enableMultiView: false,
|
||||
enableOutlineViewer: false,
|
||||
editorFlags: {},
|
||||
});
|
||||
|
||||
@@ -93,11 +91,14 @@ export function setupEditorFlags(docCollection: DocCollection) {
|
||||
|
||||
// override this flag in app settings
|
||||
// TODO(@eyhn): need a better way to manage block suite flags
|
||||
docCollection.awarenessStore.setFlag('enable_synced_doc_block', true);
|
||||
docCollection.awarenessStore.setFlag('enable_edgeless_text', true);
|
||||
docCollection.awarenessStore.setFlag('enable_color_picker', true);
|
||||
docCollection.awarenessStore.setFlag('enable_ai_chat_block', true);
|
||||
docCollection.awarenessStore.setFlag('enable_ai_onboarding', true);
|
||||
Object.entries(blocksuiteFeatureFlags).forEach(([key, value]) => {
|
||||
if (value.defaultState !== undefined) {
|
||||
docCollection.awarenessStore.setFlag(
|
||||
key as keyof BlockSuiteFlags,
|
||||
value.defaultState
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('syncEditorFlags', err);
|
||||
}
|
||||
@@ -142,3 +143,89 @@ export const appSettingAtom = atom<
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export type BuildChannel = 'stable' | 'beta' | 'canary' | 'internal';
|
||||
|
||||
export type FeedbackType = 'discord' | 'email' | 'github';
|
||||
|
||||
export type PreconditionType = () => boolean | undefined;
|
||||
|
||||
export type Flag<K extends string> = Partial<{
|
||||
[key in K]: {
|
||||
displayName: string;
|
||||
description?: string;
|
||||
precondition?: PreconditionType;
|
||||
defaultState?: boolean; // default to open and not controlled by user
|
||||
feedbackType?: FeedbackType;
|
||||
};
|
||||
}>;
|
||||
|
||||
const isNotStableBuild: PreconditionType = () => {
|
||||
return runtimeConfig.appBuildType !== 'stable';
|
||||
};
|
||||
const isDesktopEnvironment: PreconditionType = () => environment.isDesktop;
|
||||
const neverShow: PreconditionType = () => false;
|
||||
|
||||
export const blocksuiteFeatureFlags: Flag<keyof BlockSuiteFlags> = {
|
||||
enable_database_attachment_note: {
|
||||
displayName: 'Database Attachment Note',
|
||||
description: 'Allows adding notes to database attachments.',
|
||||
precondition: isNotStableBuild,
|
||||
},
|
||||
enable_database_statistics: {
|
||||
displayName: 'Database Block Statistics',
|
||||
description: 'Shows statistics for database blocks.',
|
||||
precondition: isNotStableBuild,
|
||||
},
|
||||
enable_block_query: {
|
||||
displayName: 'Todo Block Query',
|
||||
description: 'Enables querying of todo blocks.',
|
||||
precondition: isNotStableBuild,
|
||||
},
|
||||
enable_synced_doc_block: {
|
||||
displayName: 'Synced Doc Block',
|
||||
description: 'Enables syncing of doc blocks.',
|
||||
precondition: neverShow,
|
||||
defaultState: true,
|
||||
},
|
||||
enable_edgeless_text: {
|
||||
displayName: 'Edgeless Text',
|
||||
description: 'Enables edgeless text blocks.',
|
||||
precondition: neverShow,
|
||||
defaultState: true,
|
||||
},
|
||||
enable_color_picker: {
|
||||
displayName: 'Color Picker',
|
||||
description: 'Enables color picker blocks.',
|
||||
precondition: neverShow,
|
||||
defaultState: true,
|
||||
},
|
||||
enable_ai_chat_block: {
|
||||
displayName: 'AI Chat Block',
|
||||
description: 'Enables AI chat blocks.',
|
||||
precondition: neverShow,
|
||||
defaultState: true,
|
||||
},
|
||||
enable_ai_onboarding: {
|
||||
displayName: 'AI Onboarding',
|
||||
description: 'Enables AI onboarding.',
|
||||
precondition: neverShow,
|
||||
defaultState: true,
|
||||
},
|
||||
enable_expand_database_block: {
|
||||
displayName: 'Expand Database Block',
|
||||
description: 'Enables expanding of database blocks.',
|
||||
precondition: neverShow,
|
||||
defaultState: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const affineFeatureFlags: Flag<keyof AppSetting> = {
|
||||
enableMultiView: {
|
||||
displayName: 'Split View',
|
||||
description:
|
||||
'The Split View feature in AFFiNE allows users to divide their workspace into multiple sections, enabling simultaneous viewing and editing of different documents.The Split View feature in AFFiNE allows users to divide their workspace into multiple sections, enabling simultaneous viewing and editing of different documents.',
|
||||
feedbackType: 'discord',
|
||||
precondition: isDesktopEnvironment,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -495,7 +495,8 @@ export class LiveData<T = unknown>
|
||||
throw this.poisonedError;
|
||||
}
|
||||
this.ops$.next('watch');
|
||||
setImmediate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises -- never throw
|
||||
Promise.resolve().then(() => {
|
||||
this.ops$.next('unwatch');
|
||||
});
|
||||
return this.raw$.value;
|
||||
|
||||
@@ -34,6 +34,8 @@ export class Workspace extends Entity {
|
||||
},
|
||||
idGenerator: () => nanoid(),
|
||||
schema: globalBlockSuiteSchema,
|
||||
disableBacklinkIndex: true,
|
||||
disableSearchIndex: true,
|
||||
});
|
||||
}
|
||||
return this._docCollection;
|
||||
|
||||
@@ -61,6 +61,8 @@ export class TestingWorkspaceLocalProvider
|
||||
blobSources: {
|
||||
main: blobStorage,
|
||||
},
|
||||
disableBacklinkIndex: true,
|
||||
disableSearchIndex: true,
|
||||
});
|
||||
|
||||
// apply initial state
|
||||
@@ -95,6 +97,8 @@ export class TestingWorkspaceLocalProvider
|
||||
const bs = new DocCollection({
|
||||
id,
|
||||
schema: globalBlockSuiteSchema,
|
||||
disableBacklinkIndex: true,
|
||||
disableSearchIndex: true,
|
||||
});
|
||||
|
||||
applyUpdate(bs.doc, data);
|
||||
|
||||
@@ -178,10 +178,13 @@ export class FullTextInvertedIndex implements InvertedIndex {
|
||||
const queryTokens = new GeneralTokenizer().tokenize(term);
|
||||
const matched = new Map<
|
||||
number,
|
||||
{
|
||||
score: number[];
|
||||
positions: Map<number, [number, number][]>;
|
||||
}
|
||||
Map<
|
||||
number, // index
|
||||
{
|
||||
score: number;
|
||||
ranges: [number, number][];
|
||||
}
|
||||
>
|
||||
>();
|
||||
for (const token of queryTokens) {
|
||||
const key = InvertedIndexKey.forString(this.fieldKey, token.term);
|
||||
@@ -244,30 +247,48 @@ export class FullTextInvertedIndex implements InvertedIndex {
|
||||
|
||||
// normalize score
|
||||
const maxScore = submatched.reduce((acc, s) => Math.max(acc, s.score), 0);
|
||||
const minScore = submatched.reduce((acc, s) => Math.min(acc, s.score), 1);
|
||||
const minScore = submatched.reduce((acc, s) => Math.min(acc, s.score), 0);
|
||||
for (const { nid, score, position } of submatched) {
|
||||
const normalizedScore = (score - minScore) / (maxScore - minScore);
|
||||
const match = matched.get(nid) || {
|
||||
score: [] as number[],
|
||||
positions: new Map(),
|
||||
const normalizedScore =
|
||||
maxScore === minScore
|
||||
? score
|
||||
: (score - minScore) / (maxScore - minScore);
|
||||
const match =
|
||||
matched.get(nid) ??
|
||||
new Map<
|
||||
number, // index
|
||||
{
|
||||
score: number;
|
||||
ranges: [number, number][];
|
||||
}
|
||||
>();
|
||||
const item = match.get(position.index) || {
|
||||
score: 0,
|
||||
ranges: [],
|
||||
};
|
||||
match.score.push(normalizedScore);
|
||||
const ranges = match.positions.get(position.index) || [];
|
||||
ranges.push(...position.ranges);
|
||||
match.positions.set(position.index, ranges);
|
||||
item.score += normalizedScore;
|
||||
item.ranges.push(...position.ranges);
|
||||
match.set(position.index, item);
|
||||
matched.set(nid, match);
|
||||
}
|
||||
}
|
||||
const match = new Match();
|
||||
for (const [nid, { score, positions }] of matched) {
|
||||
match.addScore(
|
||||
nid,
|
||||
score.reduce((acc, s) => acc + s, 0)
|
||||
);
|
||||
|
||||
for (const [index, ranges] of positions) {
|
||||
match.addHighlighter(nid, this.fieldKey, index, ranges);
|
||||
for (const [nid, items] of matched) {
|
||||
if (items.size === 0) {
|
||||
break;
|
||||
}
|
||||
let highestScore = -1;
|
||||
let highestIndex = -1;
|
||||
let highestRanges: [number, number][] = [];
|
||||
for (const [index, { score, ranges }] of items) {
|
||||
if (score > highestScore) {
|
||||
highestScore = score;
|
||||
highestIndex = index;
|
||||
highestRanges = ranges;
|
||||
}
|
||||
}
|
||||
match.addScore(nid, highestScore);
|
||||
match.addHighlighter(nid, this.fieldKey, highestIndex, highestRanges);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/admin",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@affine/core": "workspace:*",
|
||||
@@ -34,6 +34,7 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.1",
|
||||
"@sentry/react": "^8.9.0",
|
||||
"@tanstack/react-table": "^8.19.3",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.1.5",
|
||||
|
||||
@@ -23,8 +23,8 @@ const Redirect = function Redirect() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
if (!location.pathname.startsWith('/admin')) {
|
||||
navigate('/admin', { replace: true });
|
||||
if (!location.pathname.startsWith('/admin/accounts')) {
|
||||
navigate('/admin/accounts', { replace: true });
|
||||
}
|
||||
}, [location, navigate]);
|
||||
return null;
|
||||
@@ -41,15 +41,31 @@ export const router = _createBrowserRouter(
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
lazy: () => import('./modules/home'),
|
||||
element: <Redirect />,
|
||||
},
|
||||
{
|
||||
path: '/admin/accounts',
|
||||
lazy: () => import('./modules/accounts'),
|
||||
},
|
||||
{
|
||||
path: '/admin/auth',
|
||||
lazy: () => import('./modules/auth'),
|
||||
},
|
||||
{
|
||||
path: '/admin/users',
|
||||
lazy: () => import('./modules/users'),
|
||||
path: '/admin/ai',
|
||||
lazy: () => import('./modules/ai'),
|
||||
},
|
||||
{
|
||||
path: '/admin/setup',
|
||||
lazy: () => import('./modules/setup'),
|
||||
},
|
||||
{
|
||||
path: '/admin/config',
|
||||
lazy: () => import('./modules/config'),
|
||||
},
|
||||
{
|
||||
path: '/admin/settings',
|
||||
lazy: () => import('./modules/settings'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -52,23 +52,30 @@ interface SheetContentProps
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = 'right', className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContentProps & { withoutCloseButton?: boolean }
|
||||
>(
|
||||
(
|
||||
{ side = 'right', className, children, withoutCloseButton, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!withoutCloseButton && (
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
);
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@affine/admin/components/ui/avatar';
|
||||
import type { UserType } from '@affine/graphql';
|
||||
import { FeatureType } from '@affine/graphql';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
LockIcon,
|
||||
MailIcon,
|
||||
MailWarningIcon,
|
||||
UnlockIcon,
|
||||
UserIcon,
|
||||
} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { DataTableRowActions } from './data-table-row-actions';
|
||||
|
||||
const StatusItem = ({
|
||||
condition,
|
||||
IconTrue,
|
||||
IconFalse,
|
||||
textTrue,
|
||||
textFalse,
|
||||
}: {
|
||||
condition: boolean | null;
|
||||
IconTrue: ReactNode;
|
||||
IconFalse: ReactNode;
|
||||
textTrue: string;
|
||||
textFalse: string;
|
||||
}) => (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex gap-2 items-center',
|
||||
!condition ? 'text-red-500 opacity-100' : 'opacity-25'
|
||||
)}
|
||||
>
|
||||
{condition ? (
|
||||
<>
|
||||
{IconTrue}
|
||||
{textTrue}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{IconFalse}
|
||||
{textFalse}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const columns: ColumnDef<UserType>[] = [
|
||||
{
|
||||
accessorKey: 'info',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-3 items-center max-w-[50vw] overflow-hidden">
|
||||
<Avatar className="w-10 h-10">
|
||||
<AvatarImage src={row.original.avatarUrl ?? undefined} />
|
||||
<AvatarFallback>
|
||||
<UserIcon size={20} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-1 max-w-full overflow-hidden">
|
||||
<div className="text-sm font-medium max-w-full overflow-hidden gap-[6px]">
|
||||
<span>{row.original.name}</span>{' '}
|
||||
{row.original.features.includes(FeatureType.Admin) && (
|
||||
<span
|
||||
className="rounded p-1 text-xs"
|
||||
style={{
|
||||
backgroundColor: 'rgba(30, 150, 235, 0.20)',
|
||||
color: 'rgba(30, 150, 235, 1)',
|
||||
}}
|
||||
>
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs font-medium opacity-50 max-w-full overflow-hidden">
|
||||
{row.original.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'property',
|
||||
cell: ({ row: { original: user } }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-2 text-xs max-md:hidden">
|
||||
<div className="flex justify-end opacity-25">{user.id}</div>
|
||||
<div className="flex gap-3 items-center justify-end">
|
||||
<StatusItem
|
||||
condition={user.hasPassword}
|
||||
IconTrue={<LockIcon size={10} />}
|
||||
IconFalse={<UnlockIcon size={10} />}
|
||||
textTrue="Password Set"
|
||||
textFalse="No Password"
|
||||
/>
|
||||
|
||||
<StatusItem
|
||||
condition={user.emailVerified}
|
||||
IconTrue={<MailIcon size={10} />}
|
||||
IconFalse={<MailWarningIcon size={10} />}
|
||||
textTrue="Email Verified"
|
||||
textFalse="Email Not Verified"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DataTableRowActions user={user} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@affine/admin/components/ui/select';
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronsLeftIcon,
|
||||
ChevronsRightIcon,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useTransition } from 'react';
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
// to handle the error: a component suspended while responding to synchronous input.
|
||||
// This will cause the UI to be replaced with a loading indicator.
|
||||
// To fix, updates that suspend should be wrapped with startTransition.
|
||||
const onPageSizeChange = useCallback(
|
||||
(value: string) => {
|
||||
startTransition(() => {
|
||||
table.setPageSize(Number(value));
|
||||
});
|
||||
},
|
||||
[table]
|
||||
);
|
||||
const handleFirstPage = useCallback(() => {
|
||||
startTransition(() => {
|
||||
table.setPageIndex(0);
|
||||
});
|
||||
}, [startTransition, table]);
|
||||
const handlePreviousPage = useCallback(() => {
|
||||
startTransition(() => {
|
||||
table.previousPage();
|
||||
});
|
||||
}, [startTransition, table]);
|
||||
const handleNextPage = useCallback(() => {
|
||||
startTransition(() => {
|
||||
table.nextPage();
|
||||
});
|
||||
}, [startTransition, table]);
|
||||
const handleLastPage = useCallback(() => {
|
||||
startTransition(() => {
|
||||
table.setPageIndex(table.getPageCount() - 1);
|
||||
});
|
||||
}, [startTransition, table]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between md:px-2">
|
||||
<div className="flex items-center md:space-x-2">
|
||||
<p className="text-sm font-medium max-md:hidden">Rows per page</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={onPageSizeChange}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 40, 80].map(pageSize => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{' '}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={handleFirstPage}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<ChevronsLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handlePreviousPage}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleNextPage}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={handleLastPage}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<ChevronsRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@affine/admin/components/ui/dropdown-menu';
|
||||
import {
|
||||
LockIcon,
|
||||
MoreVerticalIcon,
|
||||
SettingsIcon,
|
||||
TrashIcon,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useRightPanel } from '../../layout';
|
||||
import type { UserType } from '../schema';
|
||||
import { DeleteAccountDialog } from './delete-account';
|
||||
import { DiscardChanges } from './discard-changes';
|
||||
import { ResetPasswordDialog } from './reset-password';
|
||||
import { useDeleteUser, useResetUserPassword } from './use-user-management';
|
||||
import { UpdateUserForm } from './user-form';
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
user: UserType;
|
||||
}
|
||||
|
||||
export function DataTableRowActions({ user }: DataTableRowActionsProps) {
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false);
|
||||
const [discardDialogOpen, setDiscardDialogOpen] = useState(false);
|
||||
const { setRightPanelContent, openPanel, isOpen, closePanel } =
|
||||
useRightPanel();
|
||||
|
||||
const deleteUser = useDeleteUser();
|
||||
const { resetPasswordLink, onResetPassword } = useResetUserPassword();
|
||||
|
||||
const openResetPasswordDialog = useCallback(() => {
|
||||
onResetPassword(user.id, () => setResetPasswordDialogOpen(true));
|
||||
}, [onResetPassword, user.id]);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard
|
||||
.writeText(resetPasswordLink)
|
||||
.then(() => {
|
||||
toast('Reset password link copied to clipboard');
|
||||
setResetPasswordDialogOpen(false);
|
||||
})
|
||||
.catch(e => {
|
||||
toast.error('Failed to copy reset password link: ' + e.message);
|
||||
});
|
||||
}, [resetPasswordLink]);
|
||||
|
||||
const onDeleting = useCallback(() => {
|
||||
if (isOpen) {
|
||||
closePanel();
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
}, [closePanel, isOpen]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
deleteUser(user.id, onDeleting);
|
||||
}, [deleteUser, onDeleting, user.id]);
|
||||
|
||||
const openDeleteDialog = useCallback(() => {
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeDeleteDialog = useCallback(() => {
|
||||
setDeleteDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleDiscardChangesCancel = useCallback(() => {
|
||||
setDiscardDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setRightPanelContent(
|
||||
<UpdateUserForm
|
||||
user={user}
|
||||
onComplete={closePanel}
|
||||
onResetPassword={openResetPasswordDialog}
|
||||
onDeleteAccount={openDeleteDialog}
|
||||
/>
|
||||
);
|
||||
if (discardDialogOpen) {
|
||||
handleDiscardChangesCancel();
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
openPanel();
|
||||
}
|
||||
}, [
|
||||
closePanel,
|
||||
discardDialogOpen,
|
||||
handleDiscardChangesCancel,
|
||||
isOpen,
|
||||
openDeleteDialog,
|
||||
openPanel,
|
||||
openResetPasswordDialog,
|
||||
setRightPanelContent,
|
||||
user,
|
||||
]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
if (isOpen) {
|
||||
setDiscardDialogOpen(true);
|
||||
} else {
|
||||
handleConfirm();
|
||||
}
|
||||
}, [handleConfirm, isOpen]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-end items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
|
||||
>
|
||||
<MoreVerticalIcon size={20} />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[214px] p-[5px] gap-2">
|
||||
<div className="px-2 py-[6px] text-sm font-semibold overflow-hidden text-ellipsis text-nowrap">
|
||||
{user.name}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="px-2 py-[6px] text-sm font-medium gap-2 cursor-pointer"
|
||||
onSelect={openResetPasswordDialog}
|
||||
>
|
||||
<LockIcon size={16} /> Reset Password
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleEdit}
|
||||
className="px-2 py-[6px] text-sm font-medium gap-2 cursor-pointer"
|
||||
>
|
||||
<SettingsIcon size={16} /> Edit
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="px-2 py-[6px] text-sm font-medium gap-2 text-red-500 cursor-pointer focus:text-red-500"
|
||||
onSelect={openDeleteDialog}
|
||||
>
|
||||
<TrashIcon size={16} /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DeleteAccountDialog
|
||||
email={user.email}
|
||||
open={deleteDialogOpen}
|
||||
onClose={closeDeleteDialog}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
<ResetPasswordDialog
|
||||
link={resetPasswordLink}
|
||||
open={resetPasswordDialogOpen}
|
||||
onOpenChange={setResetPasswordDialogOpen}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
<DiscardChanges
|
||||
open={discardDialogOpen}
|
||||
onOpenChange={setDiscardDialogOpen}
|
||||
onClose={handleDiscardChangesCancel}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import { useQuery } from '@affine/core/hooks/use-query';
|
||||
import { getUserByEmailQuery } from '@affine/graphql';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
import type { SetStateAction } from 'react';
|
||||
import { startTransition, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useRightPanel } from '../../layout';
|
||||
import { DiscardChanges } from './discard-changes';
|
||||
import { CreateUserForm } from './user-form';
|
||||
|
||||
interface DataTableToolbarProps<TData> {
|
||||
data: TData[];
|
||||
setDataTable: (data: TData[]) => void;
|
||||
}
|
||||
|
||||
function useDebouncedValue<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
data,
|
||||
setDataTable,
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
const [value, setValue] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const debouncedValue = useDebouncedValue(value, 500);
|
||||
const { setRightPanelContent, openPanel, closePanel, isOpen } =
|
||||
useRightPanel();
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setRightPanelContent(<CreateUserForm onComplete={closePanel} />);
|
||||
if (dialogOpen) {
|
||||
setDialogOpen(false);
|
||||
}
|
||||
if (!isOpen) {
|
||||
openPanel();
|
||||
}
|
||||
}, [setRightPanelContent, closePanel, dialogOpen, isOpen, openPanel]);
|
||||
|
||||
const result = useQuery({
|
||||
query: getUserByEmailQuery,
|
||||
variables: {
|
||||
email: value,
|
||||
},
|
||||
}).data.userByEmail;
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
if (!debouncedValue) {
|
||||
setDataTable(data);
|
||||
} else if (result) {
|
||||
setDataTable([result as TData]);
|
||||
} else {
|
||||
setDataTable([]);
|
||||
}
|
||||
});
|
||||
}, [data, debouncedValue, result, setDataTable, value]);
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(e: { currentTarget: { value: SetStateAction<string> } }) => {
|
||||
startTransition(() => {
|
||||
setValue(e.currentTarget.value);
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenConfirm = useCallback(() => {
|
||||
if (isOpen) {
|
||||
return setDialogOpen(true);
|
||||
}
|
||||
return handleConfirm();
|
||||
}, [handleConfirm, isOpen]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
<Input
|
||||
placeholder="Search Email"
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
className="h-10 w-full mr-[10px]"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="px-4 py-2 space-x-[10px] text-sm font-medium"
|
||||
onClick={handleOpenConfirm}
|
||||
>
|
||||
<PlusIcon size={20} /> <span>Add User</span>
|
||||
</Button>
|
||||
<DiscardChanges
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
onClose={handleCancel}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from '@affine/admin/components/ui/table';
|
||||
import { useQuery } from '@affine/core/hooks/use-query';
|
||||
import { getUsersCountQuery } from '@affine/graphql';
|
||||
import type { ColumnDef, PaginationState } from '@tanstack/react-table';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||
|
||||
import { DataTablePagination } from './data-table-pagination';
|
||||
import { DataTableToolbar } from './data-table-toolbar';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pagination: PaginationState;
|
||||
onPaginationChange: Dispatch<
|
||||
SetStateAction<{
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const {
|
||||
data: { usersCount },
|
||||
} = useQuery({
|
||||
query: getUsersCountQuery,
|
||||
});
|
||||
|
||||
const [tableData, setTableData] = useState(data);
|
||||
const table = useReactTable({
|
||||
data: tableData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
rowCount: usersCount,
|
||||
enableFilters: true,
|
||||
onPaginationChange: onPaginationChange,
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setTableData(data);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-5 px-6 h-full">
|
||||
<DataTableToolbar setDataTable={setTableData} data={data} />
|
||||
<ScrollArea className="rounded-md border max-h-[75vh] h-full">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map(row => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
{row.getVisibleCells().map(cell => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export const DeleteAccountDialog = ({
|
||||
email,
|
||||
open,
|
||||
onClose,
|
||||
onDelete,
|
||||
onOpenChange,
|
||||
}: {
|
||||
email: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onDelete: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
const [input, setInput] = useState('');
|
||||
const handleInput = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInput(event.target.value);
|
||||
},
|
||||
[setInput]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setInput('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Account ?</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-bold">{email}</span> will be permanently
|
||||
deleted. This operation is irreversible. Please proceed with
|
||||
caution.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={handleInput}
|
||||
placeholder="Please type email to confirm"
|
||||
className="placeholder:opacity-50"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
|
||||
export const DiscardChanges = ({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="leading-7">Discard Changes</DialogTitle>
|
||||
<DialogDescription className="leading-6">
|
||||
Changes to this user will not be saved.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<div className="flex justify-end items-center w-full space-x-4">
|
||||
<Button type="button" onClick={onClose} variant="outline">
|
||||
<span>Cancel</span>
|
||||
</Button>
|
||||
<Button type="button" onClick={onConfirm} variant="destructive">
|
||||
<span>Discard</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
export const Logo = () => {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M18.6172 16.2657C18.314 15.7224 17.8091 14.8204 17.3102 13.9295C17.1589 13.6591 17.0086 13.3904 16.8644 13.1326C16.5679 12.6025 16.2978 12.1191 16.1052 11.7741C14.7688 9.38998 12.1376 4.66958 10.823 2.33541C10.418 1.68481 9.47943 1.73636 9.13092 2.4101C8.73553 3.1175 8.3004 3.89538 7.84081 4.71744C7.69509 4.97831 7.54631 5.24392 7.396 5.51268C5.48122 8.93556 3.24035 12.9423 1.64403 15.7961C1.5625 15.9486 1.41067 16.1974 1.33475 16.362C1.20176 16.6591 1.22775 17.0294 1.39538 17.304C1.58441 17.629 1.93802 17.8073 2.29927 17.7889C2.73389 17.7889 3.65561 17.7884 4.84738 17.7889C5.13016 17.7889 5.42823 17.7889 5.73853 17.7889C9.88246 17.7889 16.2127 17.7915 17.7663 17.7889C18.5209 17.7905 18.9942 16.9363 18.6182 16.2652L18.6172 16.2657ZM9.69699 13.2342L8.93424 11.8704C8.80024 11.6305 8.96787 11.3307 9.23588 11.3307H10.7614C11.0299 11.3307 11.1975 11.6305 11.063 11.8704L10.3003 13.2342C10.1663 13.474 9.83099 13.474 9.69648 13.2342H9.69699ZM8.41912 10.6943C8.35594 10.5281 8.30142 10.3593 8.25658 10.1878L10.7802 10.6943H8.41912ZM9.57165 14.2824C9.46414 14.4223 9.3495 14.5553 9.22823 14.6816L8.39109 12.1723L9.57114 14.2824H9.57165ZM12.0061 11.458C12.1768 11.4843 12.346 11.5206 12.5121 11.5658L10.8256 13.5687L12.0061 11.458ZM8.10117 9.33318C8.07417 9.07967 8.06245 8.82353 8.06347 8.56687L11.3962 10.2452L8.10067 9.33371L8.10117 9.33318ZM7.70579 11.8456L8.58828 15.2459C8.38905 15.3969 8.18015 15.5357 7.96411 15.663L7.70528 11.8456H7.70579ZM13.3069 11.8546C13.5332 11.9571 13.7538 12.075 13.9688 12.2043L10.8944 14.345L13.3069 11.8546ZM8.1399 7.48447C8.20104 7.01847 8.2953 6.55932 8.40943 6.1191L13.4725 10.6623L8.14041 7.48447H8.1399ZM7.01793 16.1369C6.59656 16.3152 6.16449 16.4603 5.73802 16.5781L7.01793 9.78129V16.1369ZM14.8386 12.8134C15.1988 13.1011 15.5371 13.4151 15.8494 13.737L9.50643 15.9912L14.8386 12.8134ZM10.2203 3.56456C11.1537 5.23655 12.509 7.66118 13.8002 9.96905L8.97959 4.99304C9.26288 4.48707 9.5314 4.00688 9.77902 3.56351C9.87736 3.38837 10.1219 3.38837 10.2203 3.56351V3.56456ZM2.69109 16.2358C2.95655 15.7629 3.32137 15.1144 3.40238 14.9651C4.17074 13.5913 5.20557 11.7415 6.27454 9.8302L4.50906 16.6307C3.87674 16.6307 3.33156 16.6307 2.91171 16.6307C2.71555 16.6307 2.59275 16.4114 2.69109 16.2363V16.2358ZM17.0871 16.6318C15.6151 16.6318 12.7572 16.6318 9.91965 16.6318L16.5083 14.8094C16.8537 15.4268 17.1304 15.9212 17.3077 16.2379C17.406 16.413 17.2832 16.6318 17.0876 16.6318H17.0871Z"
|
||||
fill="black"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import { CopyIcon } from 'lucide-react';
|
||||
|
||||
export const ResetPasswordDialog = ({
|
||||
link,
|
||||
open,
|
||||
onCopy,
|
||||
onOpenChange,
|
||||
}: {
|
||||
link: string;
|
||||
open: boolean;
|
||||
onCopy: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="leading-7">Account Recovery Link</DialogTitle>
|
||||
<DialogDescription className="leading-6">
|
||||
Please send this recovery link to the user and instruct them to
|
||||
complete it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<div className="flex justify-between items-center w-full space-x-4">
|
||||
<Input
|
||||
type="text"
|
||||
value={link}
|
||||
placeholder="Please type email to confirm"
|
||||
className="placeholder:opacity-50 text-ellipsis overflow-hidden whitespace-nowrap"
|
||||
readOnly
|
||||
/>
|
||||
<Button type="button" onClick={onCopy} className="space-x-[10px]">
|
||||
<CopyIcon size={20} /> <span>Copy and Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import {
|
||||
useMutateQueryResource,
|
||||
useMutation,
|
||||
} from '@affine/core/hooks/use-mutation';
|
||||
import {
|
||||
createChangePasswordUrlMutation,
|
||||
createUserMutation,
|
||||
deleteUserMutation,
|
||||
listUsersQuery,
|
||||
updateAccountFeaturesMutation,
|
||||
updateAccountMutation,
|
||||
} from '@affine/graphql';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { UserInput } from '../schema';
|
||||
|
||||
export const useCreateUser = () => {
|
||||
const {
|
||||
trigger: createAccount,
|
||||
isMutating: creating,
|
||||
error,
|
||||
} = useMutation({
|
||||
mutation: createUserMutation,
|
||||
});
|
||||
|
||||
const { trigger: updateAccountFeatures } = useMutation({
|
||||
mutation: updateAccountFeaturesMutation,
|
||||
});
|
||||
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
const create = useAsyncCallback(
|
||||
async ({ name, email, features }: UserInput) => {
|
||||
try {
|
||||
const account = await createAccount({
|
||||
input: {
|
||||
name,
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
await updateAccountFeatures({
|
||||
userId: account.createUser.id,
|
||||
features,
|
||||
});
|
||||
await revalidate(listUsersQuery);
|
||||
toast('Account updated successfully');
|
||||
} catch (e) {
|
||||
toast.error('Failed to update account: ' + (e as Error).message);
|
||||
}
|
||||
},
|
||||
[createAccount, revalidate]
|
||||
);
|
||||
|
||||
return { creating: creating || !!error, create };
|
||||
};
|
||||
|
||||
export const useUpdateUser = () => {
|
||||
const {
|
||||
trigger: updateAccount,
|
||||
isMutating: updating,
|
||||
error,
|
||||
} = useMutation({
|
||||
mutation: updateAccountMutation,
|
||||
});
|
||||
|
||||
const { trigger: updateAccountFeatures } = useMutation({
|
||||
mutation: updateAccountFeaturesMutation,
|
||||
});
|
||||
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
const update = useAsyncCallback(
|
||||
async ({
|
||||
userId,
|
||||
name,
|
||||
email,
|
||||
features,
|
||||
}: UserInput & { userId: string }) => {
|
||||
try {
|
||||
await updateAccount({
|
||||
id: userId,
|
||||
input: {
|
||||
name,
|
||||
email,
|
||||
},
|
||||
});
|
||||
await updateAccountFeatures({
|
||||
userId,
|
||||
features,
|
||||
});
|
||||
await revalidate(listUsersQuery);
|
||||
toast('Account updated successfully');
|
||||
} catch (e) {
|
||||
toast.error('Failed to update account: ' + (e as Error).message);
|
||||
}
|
||||
},
|
||||
[revalidate, updateAccount]
|
||||
);
|
||||
|
||||
return { updating: updating || !!error, update };
|
||||
};
|
||||
|
||||
export const useResetUserPassword = () => {
|
||||
const [resetPasswordLink, setResetPasswordLink] = useState('');
|
||||
const { trigger: resetPassword } = useMutation({
|
||||
mutation: createChangePasswordUrlMutation,
|
||||
});
|
||||
|
||||
const onResetPassword = useCallback(
|
||||
async (id: string, callback?: () => void) => {
|
||||
setResetPasswordLink('');
|
||||
resetPassword({
|
||||
userId: id,
|
||||
callbackUrl: '/auth/changePassword?isClient=false',
|
||||
})
|
||||
.then(res => {
|
||||
setResetPasswordLink(res.createChangePasswordUrl);
|
||||
callback?.();
|
||||
})
|
||||
.catch(e => {
|
||||
toast.error('Failed to reset password: ' + e.message);
|
||||
});
|
||||
},
|
||||
[resetPassword]
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
resetPasswordLink,
|
||||
onResetPassword,
|
||||
};
|
||||
}, [onResetPassword, resetPasswordLink]);
|
||||
};
|
||||
|
||||
export const useDeleteUser = () => {
|
||||
const { trigger: deleteUserById } = useMutation({
|
||||
mutation: deleteUserMutation,
|
||||
});
|
||||
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
const deleteById = useAsyncCallback(
|
||||
async (id: string, callback?: () => void) => {
|
||||
await deleteUserById({ id })
|
||||
.then(async () => {
|
||||
await revalidate(listUsersQuery);
|
||||
toast('User deleted successfully');
|
||||
callback?.();
|
||||
})
|
||||
.catch(e => {
|
||||
toast.error('Failed to delete user: ' + e.message);
|
||||
});
|
||||
},
|
||||
[deleteUserById, revalidate]
|
||||
);
|
||||
|
||||
return deleteById;
|
||||
};
|
||||
@@ -0,0 +1,288 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import { Label } from '@affine/admin/components/ui/label';
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import { Switch } from '@affine/admin/components/ui/switch';
|
||||
import type { FeatureType } from '@affine/graphql';
|
||||
import { CheckIcon, ChevronRightIcon, XIcon } from 'lucide-react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useServerConfig } from '../../common';
|
||||
import type { UserInput, UserType } from '../schema';
|
||||
import { useCreateUser, useUpdateUser } from './use-user-management';
|
||||
|
||||
type UserFormProps = {
|
||||
title: string;
|
||||
defaultValue?: Partial<UserInput>;
|
||||
onClose: () => void;
|
||||
onConfirm: (user: UserInput) => void;
|
||||
onValidate: (user: Partial<UserInput>) => boolean;
|
||||
actions?: React.ReactNode;
|
||||
};
|
||||
|
||||
function UserForm({
|
||||
title,
|
||||
defaultValue,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onValidate,
|
||||
actions,
|
||||
}: UserFormProps) {
|
||||
const serverConfig = useServerConfig();
|
||||
|
||||
const [changes, setChanges] = useState<Partial<UserInput>>({
|
||||
features: defaultValue?.features ?? [],
|
||||
});
|
||||
|
||||
const setField = useCallback(
|
||||
<K extends keyof UserInput>(
|
||||
field: K,
|
||||
value: UserInput[K] | ((prev: UserInput[K] | undefined) => UserInput[K])
|
||||
) => {
|
||||
setChanges(changes => ({
|
||||
...changes,
|
||||
[field]:
|
||||
typeof value === 'function' ? value(changes[field] as any) : value,
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const canSave = useMemo(() => {
|
||||
return onValidate(changes);
|
||||
}, [onValidate, changes]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (!canSave) {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-expect-error checked
|
||||
onConfirm(changes);
|
||||
}, [canSave, changes, onConfirm]);
|
||||
|
||||
const onFeatureChanged = useCallback(
|
||||
(feature: FeatureType, checked: boolean) => {
|
||||
setField('features', (features = []) => {
|
||||
if (checked) {
|
||||
return [...features, feature];
|
||||
}
|
||||
return features.filter(f => f !== feature);
|
||||
});
|
||||
},
|
||||
[setField]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-1">
|
||||
<div className=" flex justify-between items-center py-[10px] px-6">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="w-7 h-7"
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
>
|
||||
<XIcon size={20} />
|
||||
</Button>
|
||||
<span className="text-base font-medium">{title}</span>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className="w-7 h-7"
|
||||
variant="ghost"
|
||||
onClick={handleConfirm}
|
||||
disabled={!canSave}
|
||||
>
|
||||
<CheckIcon size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="p-4 flex-grow overflow-y-auto space-y-[10px]">
|
||||
<div className="flex flex-col rounded-md border py-4 gap-4">
|
||||
<InputItem
|
||||
label="Name"
|
||||
field="name"
|
||||
value={changes.name ?? defaultValue?.name}
|
||||
onChange={setField}
|
||||
/>
|
||||
<Separator />
|
||||
<InputItem
|
||||
label="Email"
|
||||
field="email"
|
||||
value={changes.email ?? defaultValue?.email}
|
||||
onChange={setField}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md">
|
||||
{serverConfig.availableUserFeatures.map((feature, i) => (
|
||||
<div key={feature}>
|
||||
<ToggleItem
|
||||
name={feature}
|
||||
checked={(
|
||||
changes.features ??
|
||||
defaultValue?.features ??
|
||||
[]
|
||||
).includes(feature)}
|
||||
onChange={onFeatureChanged}
|
||||
/>
|
||||
{i < serverConfig.availableUserFeatures.length - 1 && (
|
||||
<Separator />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleItem({
|
||||
name,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
name: FeatureType;
|
||||
checked: boolean;
|
||||
onChange: (name: FeatureType, value: boolean) => void;
|
||||
}) {
|
||||
const onToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
onChange(name, checked);
|
||||
},
|
||||
[name, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Label className="flex items-center justify-between px-4 py-3">
|
||||
<span>{name}</span>
|
||||
<Switch checked={checked} onCheckedChange={onToggle} />
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
function InputItem({
|
||||
label,
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
field: keyof UserInput;
|
||||
value?: string;
|
||||
onChange: (field: keyof UserInput, value: string) => void;
|
||||
}) {
|
||||
const onValueChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(field, e.target.value);
|
||||
},
|
||||
[field, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-5 space-y-3">
|
||||
<Label className="text-sm font-medium">{label}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
className="py-2 px-3 text-base font-normal"
|
||||
defaultValue={value}
|
||||
onChange={onValueChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const validateCreateUser = (user: Partial<UserInput>) => {
|
||||
return !!user.name && !!user.email && !!user.features;
|
||||
};
|
||||
|
||||
const validateUpdateUser = (user: Partial<UserInput>) => {
|
||||
return !!user.name || !!user.email;
|
||||
};
|
||||
|
||||
export function CreateUserForm({ onComplete }: { onComplete: () => void }) {
|
||||
const { create, creating } = useCreateUser();
|
||||
useEffect(() => {
|
||||
if (creating) {
|
||||
return () => {
|
||||
onComplete();
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
}, [creating, onComplete]);
|
||||
return (
|
||||
<UserForm
|
||||
title="Create User"
|
||||
onClose={onComplete}
|
||||
onConfirm={create}
|
||||
onValidate={validateCreateUser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateUserForm({
|
||||
user,
|
||||
onResetPassword,
|
||||
onDeleteAccount,
|
||||
onComplete,
|
||||
}: {
|
||||
user: UserType;
|
||||
onResetPassword: () => void;
|
||||
onDeleteAccount: () => void;
|
||||
onComplete: () => void;
|
||||
}) {
|
||||
const { update, updating } = useUpdateUser();
|
||||
|
||||
const onUpdateUser = useCallback(
|
||||
(updates: UserInput) => {
|
||||
update({
|
||||
...updates,
|
||||
userId: user.id,
|
||||
});
|
||||
},
|
||||
[user, update]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (updating) {
|
||||
return () => {
|
||||
onComplete();
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [updating, onComplete]);
|
||||
|
||||
return (
|
||||
<UserForm
|
||||
title="Update User"
|
||||
defaultValue={user}
|
||||
onClose={onComplete}
|
||||
onConfirm={onUpdateUser}
|
||||
onValidate={validateUpdateUser}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
className="w-full flex items-center justify-between text-sm font-medium px-4 py-3"
|
||||
variant="outline"
|
||||
onClick={onResetPassword}
|
||||
>
|
||||
<span>Reset Password</span>
|
||||
<ChevronRightIcon size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full text-red-500 px-4 py-3 rounded-md flex items-center justify-between text-sm font-medium hover:text-red-500"
|
||||
variant="outline"
|
||||
onClick={onDeleteAccount}
|
||||
>
|
||||
<span>Delete Account</span>
|
||||
<ChevronRightIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
48
packages/frontend/admin/src/modules/accounts/index.tsx
Normal file
48
packages/frontend/admin/src/modules/accounts/index.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import { useQuery } from '@affine/core/hooks/use-query';
|
||||
import { listUsersQuery } from '@affine/graphql';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Layout } from '../layout';
|
||||
import { columns } from './components/columns';
|
||||
import { DataTable } from './components/data-table';
|
||||
|
||||
export function Accounts() {
|
||||
return <Layout content={<AccountPage />} />;
|
||||
}
|
||||
|
||||
export function AccountPage() {
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const {
|
||||
data: { users },
|
||||
} = useQuery({
|
||||
query: listUsersQuery,
|
||||
variables: {
|
||||
filter: {
|
||||
first: pagination.pageSize,
|
||||
skip: pagination.pageIndex * pagination.pageSize,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className=" h-screen flex-1 flex-col flex">
|
||||
<div className="flex items-center justify-between px-6 py-3 my-[2px] max-md:ml-9 max-md:mt-[2px]">
|
||||
<div className="text-base font-medium">Accounts</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<DataTable
|
||||
data={users}
|
||||
// @ts-expect-error do not complains
|
||||
columns={columns}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { Accounts as Component };
|
||||
8
packages/frontend/admin/src/modules/accounts/schema.ts
Normal file
8
packages/frontend/admin/src/modules/accounts/schema.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { FeatureType, ListUsersQuery } from '@affine/graphql';
|
||||
|
||||
export type UserType = ListUsersQuery['users'][0];
|
||||
export type UserInput = {
|
||||
name: string;
|
||||
email: string;
|
||||
features: FeatureType[];
|
||||
};
|
||||
44
packages/frontend/admin/src/modules/ai/discard-changes.tsx
Normal file
44
packages/frontend/admin/src/modules/ai/discard-changes.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
|
||||
export const DiscardChanges = ({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="leading-7">Discard Changes</DialogTitle>
|
||||
<DialogDescription className="leading-6">
|
||||
Changes to this prompt will not be saved.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<div className="flex justify-end items-center w-full space-x-4">
|
||||
<Button type="button" onClick={onClose} variant="outline">
|
||||
<span>Cancel</span>
|
||||
</Button>
|
||||
<Button type="button" onClick={onConfirm} variant="destructive">
|
||||
<span>Discard</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
146
packages/frontend/admin/src/modules/ai/edit-prompt.tsx
Normal file
146
packages/frontend/admin/src/modules/ai/edit-prompt.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import { Textarea } from '@affine/admin/components/ui/textarea';
|
||||
import { CheckIcon, XIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useRightPanel } from '../layout';
|
||||
import type { Prompt } from './prompts';
|
||||
import { usePrompt } from './use-prompt';
|
||||
|
||||
export function EditPrompt({ item }: { item: Prompt }) {
|
||||
const { closePanel } = useRightPanel();
|
||||
|
||||
const [messages, setMessages] = useState(item.messages);
|
||||
const { updatePrompt } = usePrompt();
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>, index: number) => {
|
||||
const newMessages = [...messages];
|
||||
newMessages[index] = {
|
||||
...newMessages[index],
|
||||
content: e.target.value,
|
||||
};
|
||||
setMessages(newMessages);
|
||||
},
|
||||
[messages]
|
||||
);
|
||||
const handleClose = useCallback(() => {
|
||||
setMessages(item.messages);
|
||||
closePanel();
|
||||
}, [closePanel, item.messages]);
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
updatePrompt({ name: item.name, messages });
|
||||
handleClose();
|
||||
}, [handleClose, item.name, messages, updatePrompt]);
|
||||
|
||||
const disableSave = useMemo(
|
||||
() => JSON.stringify(messages) === JSON.stringify(item.messages),
|
||||
[item.messages, messages]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setMessages(item.messages);
|
||||
}, [item.messages]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-1">
|
||||
<div className="flex justify-between items-center py-[10px] px-6 ">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="w-7 h-7"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<XIcon size={20} />
|
||||
</Button>
|
||||
<span className="text-base font-medium">Edit Prompt</span>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className="w-7 h-7"
|
||||
variant="ghost"
|
||||
onClick={onConfirm}
|
||||
disabled={disableSave}
|
||||
>
|
||||
<CheckIcon size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<ScrollArea>
|
||||
<div className="px-5 py-4 overflow-y-auto space-y-[10px] flex flex-col gap-5">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">Name</div>
|
||||
<div className="text-sm font-normal text-zinc-500">{item.name}</div>
|
||||
</div>
|
||||
{item.action ? (
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">Action</div>
|
||||
<div className="text-sm font-normal text-zinc-500">
|
||||
{item.action}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">Model</div>
|
||||
<div className="text-sm font-normal text-zinc-500">
|
||||
{item.model}
|
||||
</div>
|
||||
</div>
|
||||
{item.config ? (
|
||||
<div className="flex flex-col border rounded p-3">
|
||||
<div className="text-sm font-medium">Config</div>
|
||||
{Object.entries(item.config).map(([key, value], index) => (
|
||||
<div key={key} className="flex flex-col">
|
||||
{index !== 0 && <Separator />}
|
||||
<span className="text-sm font-normal">{key}</span>
|
||||
<span className="text-sm font-normal text-zinc-500">
|
||||
{value?.toString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="px-5 py-4 overflow-y-auto space-y-[10px] flex flex-col">
|
||||
<div className="text-sm font-medium">Messages</div>
|
||||
{messages.map((message, index) => (
|
||||
<div key={index} className="flex flex-col gap-3">
|
||||
{index !== 0 && <Separator />}
|
||||
<div>
|
||||
<div className="text-sm font-normal">Role</div>
|
||||
<div className="text-sm font-normal text-zinc-500">
|
||||
{message.role}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.params ? (
|
||||
<div>
|
||||
<div className="text-sm font-medium">Params</div>
|
||||
{Object.entries(message.params).map(([key, value], index) => (
|
||||
<div key={key} className="flex flex-col">
|
||||
{index !== 0 && <Separator />}
|
||||
<span className="text-sm font-normal">{key}</span>
|
||||
<span className="text-sm font-normal text-zinc-500">
|
||||
{value.toString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-sm font-normal">Content</div>
|
||||
<Textarea
|
||||
className=" min-h-48"
|
||||
value={message.content}
|
||||
onChange={e => handleChange(e, index)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
packages/frontend/admin/src/modules/ai/index.tsx
Normal file
41
packages/frontend/admin/src/modules/ai/index.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
|
||||
import { Prompts } from './prompts';
|
||||
|
||||
export function Ai() {
|
||||
return null;
|
||||
|
||||
// hide ai config in admin until it's ready
|
||||
// return <Layout content={<AiPage />} />;
|
||||
}
|
||||
|
||||
export function AiPage() {
|
||||
return (
|
||||
<div className=" h-screen flex-1 flex-col flex">
|
||||
<div className="flex items-center justify-between px-6 py-3 my-[2px] max-md:ml-9 max-md:mt-[2px]">
|
||||
<div className="text-base font-medium">AI</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<ScrollAreaPrimitive.Root
|
||||
className={cn('relative overflow-hidden w-full')}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block">
|
||||
<Prompts />
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
|
||||
'h-full w-2.5 border-l border-l-transparent p-[1px]'
|
||||
)}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { Ai as Component };
|
||||
69
packages/frontend/admin/src/modules/ai/keys.tsx
Normal file
69
packages/frontend/admin/src/modules/ai/keys.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import { Label } from '@affine/admin/components/ui/label';
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function Keys() {
|
||||
const [openAIKey, setOpenAIKey] = useState('');
|
||||
const [falAIKey, setFalAIKey] = useState('');
|
||||
const [unsplashKey, setUnsplashKey] = useState('');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3 py-5 px-6 w-full">
|
||||
<div className="flex items-center">
|
||||
<span className="text-xl font-semibold">Keys</span>
|
||||
</div>
|
||||
<div className="flex-grow overflow-y-auto space-y-[10px]">
|
||||
<div className="flex flex-col rounded-md border py-4 gap-4">
|
||||
<div className="px-5 space-y-3">
|
||||
<Label className="text-sm font-medium">OpenAI Key</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
className="py-2 px-3 text-base font-normal placeholder:opacity-50"
|
||||
value={openAIKey}
|
||||
placeholder="sk-xxxxxxxxxxxxx-xxxxxxxxxxxxxx"
|
||||
onChange={e => setOpenAIKey(e.target.value)}
|
||||
/>
|
||||
<Button disabled>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="px-5 space-y-3">
|
||||
<Label className="text-sm font-medium">Fal.AI Key</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
className="py-2 px-3 ext-base font-normal placeholder:opacity-50"
|
||||
value={falAIKey}
|
||||
placeholder="00000000-0000-0000-00000000:xxxxxxxxxxxxxxxxx"
|
||||
onChange={e => setFalAIKey(e.target.value)}
|
||||
/>
|
||||
<Button disabled>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="px-5 space-y-3">
|
||||
<Label className="text-sm font-medium">Unsplash Key</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
className="py-2 px-3 ext-base font-normal placeholder:opacity-50"
|
||||
value={unsplashKey}
|
||||
placeholder="00000000-0000-0000-00000000:xxxxxxxxxxxxxxxxx"
|
||||
onChange={e => setUnsplashKey(e.target.value)}
|
||||
/>
|
||||
<Button disabled>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="px-5 space-y-3 text-sm font-normal text-gray-500">
|
||||
Custom API keys may not perform as expected. AFFiNE does not
|
||||
guarantee results when using custom API keys.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
packages/frontend/admin/src/modules/ai/prompts.tsx
Normal file
113
packages/frontend/admin/src/modules/ai/prompts.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import type { CopilotPromptMessageRole } from '@affine/graphql';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useRightPanel } from '../layout';
|
||||
import { DiscardChanges } from './discard-changes';
|
||||
import { EditPrompt } from './edit-prompt';
|
||||
import { usePrompt } from './use-prompt';
|
||||
|
||||
export type Prompt = {
|
||||
__typename?: 'CopilotPromptType';
|
||||
name: string;
|
||||
model: string;
|
||||
action: string | null;
|
||||
config: {
|
||||
__typename?: 'CopilotPromptConfigType';
|
||||
jsonMode: boolean | null;
|
||||
frequencyPenalty: number | null;
|
||||
presencePenalty: number | null;
|
||||
temperature: number | null;
|
||||
topP: number | null;
|
||||
} | null;
|
||||
messages: Array<{
|
||||
__typename?: 'CopilotPromptMessageType';
|
||||
role: CopilotPromptMessageRole;
|
||||
content: string;
|
||||
params: Record<string, string> | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function Prompts() {
|
||||
const { prompts: list } = usePrompt();
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3 py-5 px-6 w-full">
|
||||
<div className="flex items-center">
|
||||
<span className="text-xl font-semibold">Prompts</span>
|
||||
</div>
|
||||
<div className="flex-grow overflow-y-auto space-y-[10px]">
|
||||
<div className="flex flex-col rounded-md border w-full">
|
||||
{list.map((item, index) => (
|
||||
<PromptRow
|
||||
key={item.name.concat(index.toString())}
|
||||
item={item}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const PromptRow = ({ item, index }: { item: Prompt; index: number }) => {
|
||||
const { setRightPanelContent, openPanel, isOpen } = useRightPanel();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const handleDiscardChangesCancel = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(
|
||||
(item: Prompt) => {
|
||||
setRightPanelContent(<EditPrompt item={item} />);
|
||||
if (dialogOpen) {
|
||||
handleDiscardChangesCancel();
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
openPanel();
|
||||
}
|
||||
},
|
||||
[
|
||||
dialogOpen,
|
||||
handleDiscardChangesCancel,
|
||||
isOpen,
|
||||
openPanel,
|
||||
setRightPanelContent,
|
||||
]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(item: Prompt) => {
|
||||
if (isOpen) {
|
||||
setDialogOpen(true);
|
||||
} else {
|
||||
handleConfirm(item);
|
||||
}
|
||||
},
|
||||
[handleConfirm, isOpen]
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
{index !== 0 && <Separator />}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex flex-col gap-1 w-full items-start px-6 py-[14px] h-full "
|
||||
onClick={() => handleEdit(item)}
|
||||
>
|
||||
<div>{item.name}</div>
|
||||
<div className="text-left w-full opacity-50 overflow-hidden text-ellipsis whitespace-nowrap break-words text-nowrap">
|
||||
{item.messages.flatMap(message => message.content).join(' ')}
|
||||
</div>
|
||||
</Button>
|
||||
<DiscardChanges
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
onClose={handleDiscardChangesCancel}
|
||||
onConfirm={() => handleConfirm(item)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
51
packages/frontend/admin/src/modules/ai/use-prompt.ts
Normal file
51
packages/frontend/admin/src/modules/ai/use-prompt.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import {
|
||||
useMutateQueryResource,
|
||||
useMutation,
|
||||
} from '@affine/core/hooks/use-mutation';
|
||||
import { useQuery } from '@affine/core/hooks/use-query';
|
||||
import { getPromptsQuery, updatePromptMutation } from '@affine/graphql';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Prompt } from './prompts';
|
||||
|
||||
export const usePrompt = () => {
|
||||
const { data } = useQuery({
|
||||
query: getPromptsQuery,
|
||||
});
|
||||
|
||||
const { trigger } = useMutation({
|
||||
mutation: updatePromptMutation,
|
||||
});
|
||||
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
const updatePrompt = useAsyncCallback(
|
||||
async ({
|
||||
name,
|
||||
messages,
|
||||
}: {
|
||||
name: string;
|
||||
messages: Prompt['messages'];
|
||||
}) => {
|
||||
await trigger({
|
||||
name,
|
||||
messages,
|
||||
})
|
||||
.then(async () => {
|
||||
await revalidate(getPromptsQuery);
|
||||
toast.success('Prompt updated successfully');
|
||||
})
|
||||
.catch(e => {
|
||||
toast(e.message);
|
||||
console.error(e);
|
||||
});
|
||||
},
|
||||
[revalidate, trigger]
|
||||
);
|
||||
|
||||
return {
|
||||
prompts: data.listCopilotPrompts,
|
||||
updatePrompt,
|
||||
};
|
||||
};
|
||||
@@ -1,14 +1,23 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import { Label } from '@affine/admin/components/ui/label';
|
||||
import { FeatureType, getUserFeaturesQuery } from '@affine/graphql';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useMutateQueryResource } from '@affine/core/hooks/use-mutation';
|
||||
import {
|
||||
FeatureType,
|
||||
getCurrentUserFeaturesQuery,
|
||||
getUserFeaturesQuery,
|
||||
} from '@affine/graphql';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useCurrentUser, useServerConfig } from '../common';
|
||||
import logo from './logo.svg';
|
||||
|
||||
export function Auth() {
|
||||
const currentUser = useCurrentUser();
|
||||
const serverConfig = useServerConfig();
|
||||
const revalidate = useMutateQueryResource();
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
const navigate = useNavigate();
|
||||
@@ -24,6 +33,14 @@ export function Auth() {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(async response => {
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.message || 'Failed to login');
|
||||
}
|
||||
await revalidate(getCurrentUserFeaturesQuery);
|
||||
return response.json();
|
||||
})
|
||||
.then(() =>
|
||||
fetch('/graphql', {
|
||||
method: 'POST',
|
||||
@@ -45,6 +62,7 @@ export function Auth() {
|
||||
},
|
||||
}) => {
|
||||
if (features.includes(FeatureType.Admin)) {
|
||||
toast.success('Logged in successfully');
|
||||
navigate('/admin');
|
||||
} else {
|
||||
toast.error('You are not an admin');
|
||||
@@ -54,9 +72,22 @@ export function Auth() {
|
||||
.catch(err => {
|
||||
toast.error(`Failed to login: ${err.message}`);
|
||||
});
|
||||
}, [navigate]);
|
||||
}, [navigate, revalidate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverConfig.initialized === false) {
|
||||
navigate('/admin/setup');
|
||||
return;
|
||||
} else if (!currentUser) {
|
||||
return;
|
||||
} else if (!currentUser?.features.includes?.(FeatureType.Admin)) {
|
||||
toast.error('You are not an admin, please login the admin account.');
|
||||
return;
|
||||
}
|
||||
}, [currentUser, navigate, serverConfig.initialized]);
|
||||
|
||||
return (
|
||||
<div className="w-full lg:grid lg:min-h-[600px] lg:grid-cols-2 xl:min-h-[800px]">
|
||||
<div className="w-full lg:grid lg:min-h-[600px] lg:grid-cols-2 xl:min-h-[800px] h-screen">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="mx-auto grid w-[350px] gap-6">
|
||||
<div className="grid gap-2 text-center">
|
||||
@@ -88,11 +119,11 @@ export function Auth() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden bg-muted lg:block">
|
||||
<div className="hidden bg-muted lg:flex lg:justify-center">
|
||||
<img
|
||||
src={logo}
|
||||
alt="Image"
|
||||
className="w-1/2 h-1/2 object-cover dark:brightness-[0.2] dark:grayscale relative top-1/4 left-1/4"
|
||||
className="h-1/2 object-cover dark:brightness-[0.2] dark:grayscale relative top-1/4 "
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
21
packages/frontend/admin/src/modules/common.ts
Normal file
21
packages/frontend/admin/src/modules/common.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useQueryImmutable } from '@affine/core/hooks/use-query';
|
||||
import {
|
||||
adminServerConfigQuery,
|
||||
getCurrentUserFeaturesQuery,
|
||||
} from '@affine/graphql';
|
||||
|
||||
export const useServerConfig = () => {
|
||||
const { data } = useQueryImmutable({
|
||||
query: adminServerConfigQuery,
|
||||
});
|
||||
|
||||
return data.serverConfig;
|
||||
};
|
||||
|
||||
export const useCurrentUser = () => {
|
||||
const { data } = useQueryImmutable({
|
||||
query: getCurrentUserFeaturesQuery,
|
||||
});
|
||||
|
||||
return data.currentUser;
|
||||
};
|
||||
88
packages/frontend/admin/src/modules/config/about.tsx
Normal file
88
packages/frontend/admin/src/modules/config/about.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { buttonVariants } from '@affine/admin/components/ui/button';
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import {
|
||||
AlbumIcon,
|
||||
ChevronRightIcon,
|
||||
GithubIcon,
|
||||
MailWarningIcon,
|
||||
UploadCloudIcon,
|
||||
} from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
|
||||
const appChannelSchema = z.enum(['stable', 'canary', 'beta', 'internal']);
|
||||
|
||||
type Channel = z.infer<typeof appChannelSchema>;
|
||||
|
||||
const appNames = {
|
||||
stable: 'AFFiNE',
|
||||
canary: 'AFFiNE Canary',
|
||||
beta: 'AFFiNE Beta',
|
||||
internal: 'AFFiNE Internal',
|
||||
} satisfies Record<Channel, string>;
|
||||
|
||||
const links = [
|
||||
{
|
||||
href: runtimeConfig.githubUrl,
|
||||
icon: <GithubIcon size={20} />,
|
||||
label: 'Star AFFiNE on GitHub',
|
||||
},
|
||||
{
|
||||
href: runtimeConfig.githubUrl,
|
||||
icon: <MailWarningIcon size={20} />,
|
||||
label: 'Report an Issue',
|
||||
},
|
||||
{
|
||||
href: 'https://docs.affine.pro/docs/self-host-affine',
|
||||
icon: <AlbumIcon size={20} />,
|
||||
label: 'Self-host Document',
|
||||
},
|
||||
{
|
||||
href: 'https://affine.pro/pricing',
|
||||
icon: <UploadCloudIcon size={20} />,
|
||||
label: 'Upgrade to Pro',
|
||||
},
|
||||
];
|
||||
|
||||
export function AboutAFFiNE() {
|
||||
const { appBuildType, appVersion, editorVersion } = runtimeConfig;
|
||||
const appName = appNames[appBuildType];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3 py-5 px-6 w-full">
|
||||
<div className="flex items-center">
|
||||
<span className="text-xl font-semibold">About AFFiNE</span>
|
||||
</div>
|
||||
<div className="overflow-y-auto space-y-[10px]">
|
||||
<div className="flex flex-col rounded-md border">
|
||||
{links.map(({ href, icon, label }, index) => (
|
||||
<div key={label + index}>
|
||||
<a
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'justify-between cursor-pointer w-full'
|
||||
)}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div>
|
||||
<ChevronRightIcon size={20} />
|
||||
</div>
|
||||
</a>
|
||||
{index < links.length - 1 && <Separator />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 text-sm font-normal text-gray-500">
|
||||
<div>{`App Version: ${appName} ${appVersion}`}</div>
|
||||
<div>{`Editor Version: ${editorVersion}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
packages/frontend/admin/src/modules/config/index.tsx
Normal file
221
packages/frontend/admin/src/modules/config/index.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@affine/admin/components/ui/card';
|
||||
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import { useQueryImmutable } from '@affine/core/hooks/use-query';
|
||||
import { getServerServiceConfigsQuery } from '@affine/graphql';
|
||||
|
||||
import { Layout } from '../layout';
|
||||
import { AboutAFFiNE } from './about';
|
||||
|
||||
type ServerConfig = {
|
||||
externalUrl: string;
|
||||
https: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type MailerConfig = {
|
||||
host: string;
|
||||
port: number;
|
||||
sender: string;
|
||||
};
|
||||
|
||||
type DatabaseConfig = {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
database: string;
|
||||
};
|
||||
|
||||
type ServerServiceConfig = {
|
||||
name: string;
|
||||
config: ServerConfig | MailerConfig | DatabaseConfig;
|
||||
};
|
||||
|
||||
export function Config() {
|
||||
return <Layout content={<ConfigPage />} />;
|
||||
}
|
||||
|
||||
export function ConfigPage() {
|
||||
return (
|
||||
<div className=" h-screen flex-1 space-y-1 flex-col flex">
|
||||
<div className="flex items-center justify-between px-6 py-3 max-md:ml-9">
|
||||
<div className="text-base font-medium">Config</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<ScrollArea>
|
||||
<ServerServiceConfig />
|
||||
<AboutAFFiNE />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ServerCard = ({ serverConfig }: { serverConfig?: ServerConfig }) => {
|
||||
if (!serverConfig) return null;
|
||||
return (
|
||||
<Card className="px-5 py-4">
|
||||
<CardHeader className="p-0">
|
||||
<CardTitle className="text-base font-semibold mb-3">Server</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 p-0">
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">Domain</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{serverConfig.host}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">Port</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{serverConfig.port}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">HTTPS Prefix</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{serverConfig.https.toString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">External Url</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{serverConfig.externalUrl}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
const DatabaseCard = ({
|
||||
databaseConfig,
|
||||
}: {
|
||||
databaseConfig?: DatabaseConfig;
|
||||
}) => {
|
||||
if (!databaseConfig) return null;
|
||||
return (
|
||||
<Card className="px-5 py-4">
|
||||
<CardHeader className="p-0">
|
||||
<CardTitle className="text-base font-semibold mb-3">Database</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 p-0">
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">Domain</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{databaseConfig.host}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">Port</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{databaseConfig.port}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">User</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{databaseConfig.user}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">Database</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{databaseConfig.database}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
const MailerCard = ({ mailerConfig }: { mailerConfig?: MailerConfig }) => {
|
||||
if (!mailerConfig) return null;
|
||||
return (
|
||||
<Card className="px-5 py-4">
|
||||
<CardHeader className="p-0">
|
||||
<CardTitle className="text-base font-semibold mb-3">Email</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 p-0">
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">Provider Domain</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{mailerConfig.host}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">Port</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{mailerConfig.port}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">Sender</div>
|
||||
<div className="text-sm text-zinc-500 font-normal">
|
||||
{mailerConfig.sender}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export function ServerServiceConfig() {
|
||||
const { data } = useQueryImmutable({
|
||||
query: getServerServiceConfigsQuery,
|
||||
});
|
||||
const server = data.serverServiceConfigs.find(
|
||||
(service: ServerServiceConfig) => service.name === 'server'
|
||||
);
|
||||
const mailer = data.serverServiceConfigs.find(
|
||||
(service: ServerServiceConfig) => service.name === 'mailer'
|
||||
);
|
||||
const database = data.serverServiceConfigs.find(
|
||||
(service: ServerServiceConfig) => service.name === 'database'
|
||||
);
|
||||
|
||||
const serverConfig = server?.config as ServerConfig | undefined;
|
||||
const mailerConfig = mailer?.config as MailerConfig | undefined;
|
||||
const databaseConfig = database?.config as DatabaseConfig | undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col py-5 px-6">
|
||||
<div className="flex items-center mb-5">
|
||||
<span className="text-2xl font-semibold">Server Config</span>
|
||||
</div>
|
||||
<div className=" items-start justify-center gap-6 rounded-lg grid grid-cols-2">
|
||||
<div className="col-span-2 grid items-start gap-6 lg:col-span-1">
|
||||
<ServerCard serverConfig={serverConfig} />
|
||||
<MailerCard mailerConfig={mailerConfig} />
|
||||
</div>
|
||||
<div className="col-span-2 grid items-start gap-6 lg:col-span-1">
|
||||
<DatabaseCard databaseConfig={databaseConfig} />
|
||||
<div className="px-5 py-4 border rounded text-sm text-zinc-500 font-normal">
|
||||
<span className="mr-1">
|
||||
These settings are controlled by Docker environment variables.
|
||||
Refer to the
|
||||
</span>
|
||||
<a
|
||||
href="https://docs.affine.pro/docs/self-host-affine"
|
||||
className="text-black underline"
|
||||
>
|
||||
Selfhost documentation.
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Config as Component };
|
||||
@@ -1,304 +0,0 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@affine/admin/components/ui/avatar';
|
||||
import { Badge } from '@affine/admin/components/ui/badge';
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@affine/admin/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@affine/admin/components/ui/table';
|
||||
import {
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
CreditCard,
|
||||
DollarSign,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Nav } from '../nav';
|
||||
|
||||
export function Dashboard() {
|
||||
return (
|
||||
<Nav>
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8">
|
||||
<MainDashBoard />
|
||||
</main>
|
||||
</Nav>
|
||||
);
|
||||
}
|
||||
|
||||
function MainDashBoard() {
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2 md:gap-8 lg:grid-cols-4">
|
||||
<Card x-chunk="dashboard-01-chunk-0">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">$45,231.89</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
+20.1% from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card x-chunk="dashboard-01-chunk-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Subscriptions</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">+2350</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
+180.1% from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card x-chunk="dashboard-01-chunk-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Sales</CardTitle>
|
||||
<CreditCard className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">+12,234</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
+19% from last month
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card x-chunk="dashboard-01-chunk-3">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Now</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">+573</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
+201 since last hour
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid gap-4 md:gap-8 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<Card className="xl:col-span-2" x-chunk="dashboard-01-chunk-4">
|
||||
<CardHeader className="flex flex-row items-center">
|
||||
<div className="grid gap-2">
|
||||
<CardTitle>Transactions</CardTitle>
|
||||
<CardDescription>
|
||||
Recent transactions from your store.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button asChild size="sm" className="ml-auto gap-1">
|
||||
<Link to="/">
|
||||
View All
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead className="hidden xl:table-column">Type</TableHead>
|
||||
<TableHead className="hidden xl:table-column">
|
||||
Status
|
||||
</TableHead>
|
||||
<TableHead className="hidden xl:table-column">Date</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="font-medium">Liam Johnson</div>
|
||||
<div className="hidden text-sm text-muted-foreground md:inline">
|
||||
liam@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden xl:table-column">Sale</TableCell>
|
||||
<TableCell className="hidden xl:table-column">
|
||||
<Badge className="text-xs" variant="outline">
|
||||
Approved
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell lg:hidden xl:table-column">
|
||||
2023-06-23
|
||||
</TableCell>
|
||||
<TableCell className="text-right">$250.00</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="font-medium">Olivia Smith</div>
|
||||
<div className="hidden text-sm text-muted-foreground md:inline">
|
||||
olivia@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden xl:table-column">
|
||||
Refund
|
||||
</TableCell>
|
||||
<TableCell className="hidden xl:table-column">
|
||||
<Badge className="text-xs" variant="outline">
|
||||
Declined
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell lg:hidden xl:table-column">
|
||||
2023-06-24
|
||||
</TableCell>
|
||||
<TableCell className="text-right">$150.00</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="font-medium">Noah Williams</div>
|
||||
<div className="hidden text-sm text-muted-foreground md:inline">
|
||||
noah@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden xl:table-column">
|
||||
Subscription
|
||||
</TableCell>
|
||||
<TableCell className="hidden xl:table-column">
|
||||
<Badge className="text-xs" variant="outline">
|
||||
Approved
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell lg:hidden xl:table-column">
|
||||
2023-06-25
|
||||
</TableCell>
|
||||
<TableCell className="text-right">$350.00</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="font-medium">Emma Brown</div>
|
||||
<div className="hidden text-sm text-muted-foreground md:inline">
|
||||
emma@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden xl:table-column">Sale</TableCell>
|
||||
<TableCell className="hidden xl:table-column">
|
||||
<Badge className="text-xs" variant="outline">
|
||||
Approved
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell lg:hidden xl:table-column">
|
||||
2023-06-26
|
||||
</TableCell>
|
||||
<TableCell className="text-right">$450.00</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="font-medium">Liam Johnson</div>
|
||||
<div className="hidden text-sm text-muted-foreground md:inline">
|
||||
liam@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden xl:table-column">Sale</TableCell>
|
||||
<TableCell className="hidden xl:table-column">
|
||||
<Badge className="text-xs" variant="outline">
|
||||
Approved
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell lg:hidden xl:table-column">
|
||||
2023-06-27
|
||||
</TableCell>
|
||||
<TableCell className="text-right">$550.00</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card x-chunk="dashboard-01-chunk-5">
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Sales</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="hidden h-9 w-9 sm:flex">
|
||||
<AvatarImage src="/avatars/01.png" alt="Avatar" />
|
||||
<AvatarFallback>OM</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid gap-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
Olivia Martin
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
olivia.martin@email.com
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto font-medium">+$1,999.00</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="hidden h-9 w-9 sm:flex">
|
||||
<AvatarImage src="/avatars/02.png" alt="Avatar" />
|
||||
<AvatarFallback>JL</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid gap-1">
|
||||
<p className="text-sm font-medium leading-none">Jackson Lee</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
jackson.lee@email.com
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto font-medium">+$39.00</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="hidden h-9 w-9 sm:flex">
|
||||
<AvatarImage src="/avatars/03.png" alt="Avatar" />
|
||||
<AvatarFallback>IN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid gap-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
Isabella Nguyen
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
isabella.nguyen@email.com
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto font-medium">+$299.00</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="hidden h-9 w-9 sm:flex">
|
||||
<AvatarImage src="/avatars/04.png" alt="Avatar" />
|
||||
<AvatarFallback>WK</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid gap-1">
|
||||
<p className="text-sm font-medium leading-none">William Kim</p>
|
||||
<p className="text-sm text-muted-foreground">will@email.com</p>
|
||||
</div>
|
||||
<div className="ml-auto font-medium">+$99.00</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="hidden h-9 w-9 sm:flex">
|
||||
<AvatarImage src="/avatars/05.png" alt="Avatar" />
|
||||
<AvatarFallback>SD</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid gap-1">
|
||||
<p className="text-sm font-medium leading-none">Sofia Davis</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
sofia.davis@email.com
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto font-medium">+$39.00</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { Dashboard as Component };
|
||||
296
packages/frontend/admin/src/modules/layout.tsx
Normal file
296
packages/frontend/admin/src/modules/layout.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@affine/admin/components/ui/resizable';
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import { TooltipProvider } from '@affine/admin/components/ui/tooltip';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import { useQuery } from '@affine/core/hooks/use-query';
|
||||
import { FeatureType, getCurrentUserFeaturesQuery } from '@affine/graphql';
|
||||
import { AlignJustifyIcon } from 'lucide-react';
|
||||
import type { ReactNode, RefObject } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { ImperativePanelHandle } from 'react-resizable-panels';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '../components/ui/button';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '../components/ui/sheet';
|
||||
import { Logo } from './accounts/components/logo';
|
||||
import { useServerConfig } from './common';
|
||||
import { NavContext } from './nav/context';
|
||||
import { Nav } from './nav/nav';
|
||||
|
||||
interface LayoutProps {
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
interface RightPanelContextType {
|
||||
isOpen: boolean;
|
||||
rightPanelContent: ReactNode;
|
||||
setRightPanelContent: (content: ReactNode) => void;
|
||||
togglePanel: () => void;
|
||||
openPanel: () => void;
|
||||
closePanel: () => void;
|
||||
}
|
||||
|
||||
const RightPanelContext = createContext<RightPanelContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
export const useRightPanel = () => {
|
||||
const context = useContext(RightPanelContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useRightPanel must be used within a RightPanelProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export function useMediaQuery(query: string) {
|
||||
const [value, setValue] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function onChange(event: MediaQueryListEvent) {
|
||||
setValue(event.matches);
|
||||
}
|
||||
|
||||
const result = matchMedia(query);
|
||||
result.addEventListener('change', onChange);
|
||||
setValue(result.matches);
|
||||
|
||||
return () => result.removeEventListener('change', onChange);
|
||||
}, [query]);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function Layout({ content }: LayoutProps) {
|
||||
const serverConfig = useServerConfig();
|
||||
const {
|
||||
data: { currentUser },
|
||||
} = useQuery({
|
||||
query: getCurrentUserFeaturesQuery,
|
||||
});
|
||||
|
||||
const [rightPanelContent, setRightPanelContent] = useState<ReactNode>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const rightPanelRef = useRef<ImperativePanelHandle>(null);
|
||||
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [activeSubTab, setActiveSubTab] = useState('auth');
|
||||
const [currentModule, setCurrentModule] = useState('auth');
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
if (rightPanelRef.current?.getSize() === 0) {
|
||||
rightPanelRef.current?.resize(30);
|
||||
}
|
||||
setOpen(true);
|
||||
}, [rightPanelRef]);
|
||||
|
||||
const handleCollapse = useCallback(() => {
|
||||
if (rightPanelRef.current?.getSize() !== 0) {
|
||||
rightPanelRef.current?.resize(0);
|
||||
}
|
||||
setOpen(false);
|
||||
}, [rightPanelRef]);
|
||||
|
||||
const openPanel = useCallback(() => {
|
||||
handleExpand();
|
||||
rightPanelRef.current?.expand();
|
||||
}, [handleExpand]);
|
||||
|
||||
const closePanel = useCallback(() => {
|
||||
handleCollapse();
|
||||
rightPanelRef.current?.collapse();
|
||||
}, [handleCollapse]);
|
||||
|
||||
const togglePanel = useCallback(
|
||||
() => (rightPanelRef.current?.isCollapsed() ? openPanel() : closePanel()),
|
||||
[closePanel, openPanel]
|
||||
);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (serverConfig.initialized === false) {
|
||||
navigate('/admin/setup');
|
||||
return;
|
||||
} else if (!currentUser) {
|
||||
navigate('/admin/auth');
|
||||
return;
|
||||
} else if (!currentUser?.features.includes?.(FeatureType.Admin)) {
|
||||
toast.error('You are not an admin, please login the admin account.');
|
||||
navigate('/admin/auth');
|
||||
return;
|
||||
}
|
||||
}, [currentUser, navigate, serverConfig.initialized]);
|
||||
|
||||
if (serverConfig.initialized === false || !currentUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RightPanelContext.Provider
|
||||
value={{
|
||||
isOpen: open,
|
||||
rightPanelContent,
|
||||
setRightPanelContent,
|
||||
togglePanel,
|
||||
openPanel,
|
||||
closePanel,
|
||||
}}
|
||||
>
|
||||
<NavContext.Provider
|
||||
value={{
|
||||
activeTab,
|
||||
activeSubTab,
|
||||
currentModule,
|
||||
setActiveTab,
|
||||
setActiveSubTab,
|
||||
setCurrentModule,
|
||||
}}
|
||||
>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="flex">
|
||||
<LeftPanel />
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<ResizablePanel id="0" order={0} minSize={50}>
|
||||
{content}
|
||||
</ResizablePanel>
|
||||
<RightPanel
|
||||
rightPanelRef={rightPanelRef}
|
||||
onExpand={handleExpand}
|
||||
onCollapse={handleCollapse}
|
||||
/>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</NavContext.Provider>
|
||||
</RightPanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const LeftPanel = () => {
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" className="fixed top-4 left-6 p-0 h-5 w-5">
|
||||
<AlignJustifyIcon size={20} />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetHeader className="hidden">
|
||||
<SheetTitle>AFFiNE</SheetTitle>
|
||||
<SheetDescription>
|
||||
Admin panel for managing accounts, AI, config, and settings
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<SheetContent side="left" className="p-0" withoutCloseButton>
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[52px] items-center gap-2 px-4 text-base font-medium'
|
||||
)}
|
||||
>
|
||||
<Logo />
|
||||
AFFiNE
|
||||
</div>
|
||||
<Separator />
|
||||
<Nav />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-w-52 max-w-sm border-r">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[52px] items-center gap-2 px-4 text-base font-medium'
|
||||
)}
|
||||
>
|
||||
<Logo />
|
||||
AFFiNE
|
||||
</div>
|
||||
<Separator />
|
||||
<Nav />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const RightPanel = ({
|
||||
rightPanelRef,
|
||||
onExpand,
|
||||
onCollapse,
|
||||
}: {
|
||||
rightPanelRef: RefObject<ImperativePanelHandle>;
|
||||
onExpand: () => void;
|
||||
onCollapse: () => void;
|
||||
}) => {
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const { rightPanelContent, isOpen } = useRightPanel();
|
||||
const onOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open) {
|
||||
onExpand();
|
||||
} else {
|
||||
onCollapse();
|
||||
}
|
||||
},
|
||||
[onExpand, onCollapse]
|
||||
);
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||
<SheetHeader className="hidden">
|
||||
<SheetTitle>Right Panel</SheetTitle>
|
||||
<SheetDescription>
|
||||
For displaying additional information
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<SheetContent side="right" className="p-0" withoutCloseButton>
|
||||
{rightPanelContent}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel
|
||||
id="1"
|
||||
order={1}
|
||||
ref={rightPanelRef}
|
||||
defaultSize={0}
|
||||
maxSize={30}
|
||||
collapsible={true}
|
||||
collapsedSize={0}
|
||||
onExpand={onExpand}
|
||||
onCollapse={onCollapse}
|
||||
>
|
||||
{rightPanelContent}
|
||||
</ResizablePanel>
|
||||
</>
|
||||
);
|
||||
};
|
||||
67
packages/frontend/admin/src/modules/nav/collapsible-item.tsx
Normal file
67
packages/frontend/admin/src/modules/nav/collapsible-item.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@affine/admin/components/ui/accordion';
|
||||
import { useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useNav } from './context';
|
||||
|
||||
export const CollapsibleItem = ({
|
||||
items,
|
||||
title,
|
||||
changeModule,
|
||||
}: {
|
||||
title: string;
|
||||
items: string[];
|
||||
changeModule?: (module: string) => void;
|
||||
}) => {
|
||||
const { activeSubTab, setActiveSubTab } = useNav();
|
||||
const handleClick = useCallback(
|
||||
(id: string) => {
|
||||
const targetElement = document.getElementById(id);
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
}
|
||||
changeModule?.(title);
|
||||
setActiveSubTab(id);
|
||||
},
|
||||
[changeModule, setActiveSubTab, title]
|
||||
);
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" className="w-full ">
|
||||
<AccordionItem value="item-1" className="border-b-0">
|
||||
<Link to={`/admin/settings#${title}`}>
|
||||
<AccordionTrigger
|
||||
onClick={() => handleClick(title)}
|
||||
className={`py-2 px-3 rounded ${activeSubTab === title ? 'bg-zinc-100' : ''}`}
|
||||
>
|
||||
{title}
|
||||
</AccordionTrigger>
|
||||
</Link>
|
||||
<AccordionContent className=" flex flex-col gap-2">
|
||||
{items.map((item, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
to={`/admin/settings#${item}`}
|
||||
className="px-3 overflow-hidden"
|
||||
>
|
||||
<AccordionContent
|
||||
onClick={() => handleClick(item)}
|
||||
className={`py-1 px-2 rounded text-ellipsis whitespace-nowrap overflow-hidden ${activeSubTab === item ? 'bg-zinc-100' : ''}`}
|
||||
>
|
||||
{item}
|
||||
</AccordionContent>
|
||||
</Link>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
21
packages/frontend/admin/src/modules/nav/context.ts
Normal file
21
packages/frontend/admin/src/modules/nav/context.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
interface NavContextType {
|
||||
activeTab: string;
|
||||
activeSubTab: string;
|
||||
currentModule: string;
|
||||
setActiveTab: (tab: string) => void;
|
||||
setActiveSubTab: (tab: string) => void;
|
||||
setCurrentModule: (module: string) => void;
|
||||
}
|
||||
|
||||
export const NavContext = createContext<NavContextType | undefined>(undefined);
|
||||
export const useNav = () => {
|
||||
const context = useContext(NavContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useNav must be used within a NavProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
@@ -1,113 +0,0 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTrigger,
|
||||
} from '@affine/admin/components/ui/sheet';
|
||||
import { Menu, Package2 } from 'lucide-react';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { UserDropdown } from './user-dropdown';
|
||||
|
||||
export function Nav({ children }: PropsWithChildren<unknown>) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full flex-col">
|
||||
<header className="sticky top-0 flex h-16 items-center gap-4 border-b bg-background px-4 md:px-6">
|
||||
<nav className="hidden flex-col gap-6 text-lg font-medium md:flex md:flex-row md:items-center md:gap-5 md:text-sm lg:gap-6">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-2 text-lg font-semibold md:text-base"
|
||||
>
|
||||
<Package2 className="h-6 w-6" />
|
||||
<span className="sr-only">AFFiNE</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="text-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/users"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Users
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Configs
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Backups
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Analytics
|
||||
</Link>
|
||||
</nav>
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0 md:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Toggle navigation menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left">
|
||||
<nav className="grid gap-6 text-lg font-medium">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-2 text-lg font-semibold"
|
||||
>
|
||||
<Package2 className="h-6 w-6" />
|
||||
<span className="sr-only">Acme Inc</span>
|
||||
</Link>
|
||||
<Link to="/" className="hover:text-foreground">
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Orders
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Products
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Customers
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Analytics
|
||||
</Link>
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<div className="flex w-full items-center justify-end gap-4 md:ml-auto md:gap-2 lg:gap-4">
|
||||
<UserDropdown />
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
packages/frontend/admin/src/modules/nav/nav.tsx
Normal file
152
packages/frontend/admin/src/modules/nav/nav.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@affine/admin/components/ui/accordion';
|
||||
import { buttonVariants } from '@affine/admin/components/ui/button';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import { ClipboardListIcon, SettingsIcon, UsersIcon } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useGetServerRuntimeConfig } from '../settings/use-get-server-runtime-config';
|
||||
import { CollapsibleItem } from './collapsible-item';
|
||||
import { useNav } from './context';
|
||||
import { UserDropdown } from './user-dropdown';
|
||||
|
||||
const TabsMap: { [key: string]: string } = {
|
||||
accounts: 'Accounts',
|
||||
ai: 'AI',
|
||||
config: 'Config',
|
||||
settings: 'Settings',
|
||||
};
|
||||
|
||||
export function Nav() {
|
||||
const { moduleList } = useGetServerRuntimeConfig();
|
||||
const { activeTab, setActiveTab, setCurrentModule } = useNav();
|
||||
|
||||
useEffect(() => {
|
||||
const path = window.location.pathname;
|
||||
for (const key in TabsMap) {
|
||||
if (path.includes(key)) {
|
||||
setActiveTab(TabsMap[key]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [setActiveTab]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-2 justify-between flex-grow overflow-hidden">
|
||||
<nav className="flex flex-col gap-1 px-2 flex-grow overflow-hidden">
|
||||
<Link
|
||||
to={'/admin/accounts'}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: activeTab === 'Accounts' ? 'default' : 'ghost',
|
||||
size: 'sm',
|
||||
}),
|
||||
activeTab === 'Accounts' &&
|
||||
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
|
||||
'justify-start',
|
||||
'flex-none'
|
||||
)}
|
||||
>
|
||||
<UsersIcon className="mr-2 h-4 w-4" />
|
||||
Accounts
|
||||
</Link>
|
||||
{/* hide ai config in admin until it's ready */}
|
||||
{/* <Link
|
||||
to={'/admin/ai'}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: activeTab === 'AI' ? 'default' : 'ghost',
|
||||
size: 'sm',
|
||||
}),
|
||||
activeTab === 'AI' &&
|
||||
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
|
||||
'justify-start',
|
||||
'flex-none'
|
||||
)}
|
||||
>
|
||||
<CpuIcon className="mr-2 h-4 w-4" />
|
||||
AI
|
||||
</Link> */}
|
||||
<Link
|
||||
to={'/admin/config'}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: activeTab === 'Config' ? 'default' : 'ghost',
|
||||
size: 'sm',
|
||||
}),
|
||||
activeTab === 'Config' &&
|
||||
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
|
||||
'justify-start',
|
||||
'flex-none'
|
||||
)}
|
||||
>
|
||||
<ClipboardListIcon className="mr-2 h-4 w-4" />
|
||||
Config
|
||||
</Link>
|
||||
|
||||
<Accordion type="multiple" className="w-full h-full overflow-hidden">
|
||||
<AccordionItem
|
||||
value="item-1"
|
||||
className="border-b-0 h-full flex flex-col gap-1"
|
||||
>
|
||||
<Link to={'/admin/settings'}>
|
||||
<AccordionTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: activeTab === 'Settings' ? 'default' : 'ghost',
|
||||
size: 'sm',
|
||||
}),
|
||||
|
||||
activeTab === 'Settings' &&
|
||||
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
|
||||
'justify-between',
|
||||
'hover:no-underline'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
</Link>
|
||||
|
||||
<AccordionContent className="h-full overflow-hidden w-full">
|
||||
<ScrollAreaPrimitive.Root
|
||||
className={cn('relative overflow-hidden w-full h-full')}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block">
|
||||
{moduleList.map(module => (
|
||||
<CollapsibleItem
|
||||
key={module.moduleName}
|
||||
items={module.keys}
|
||||
title={module.moduleName}
|
||||
changeModule={setCurrentModule}
|
||||
/>
|
||||
))}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
|
||||
'h-full w-2.5 border-l border-l-transparent p-[1px]'
|
||||
)}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</nav>
|
||||
|
||||
<UserDropdown />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@affine/admin/components/ui/avatar';
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -7,22 +12,38 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@affine/admin/components/ui/dropdown-menu';
|
||||
import { useQuery } from '@affine/core/hooks/use-query';
|
||||
import { FeatureType, getCurrentUserFeaturesQuery } from '@affine/graphql';
|
||||
import { CircleUser } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { FeatureType } from '@affine/graphql';
|
||||
import { CircleUser, MoreVertical } from 'lucide-react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useCurrentUser, useServerConfig } from '../common';
|
||||
|
||||
export function UserDropdown() {
|
||||
const {
|
||||
data: { currentUser },
|
||||
} = useQuery({
|
||||
query: getCurrentUserFeaturesQuery,
|
||||
});
|
||||
const currentUser = useCurrentUser();
|
||||
const serverConfig = useServerConfig();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
fetch('/api/auth/sign-out', {
|
||||
method: 'POST',
|
||||
})
|
||||
.then(() => {
|
||||
toast.success('Logged out successfully');
|
||||
navigate('/admin/auth');
|
||||
})
|
||||
.catch(err => {
|
||||
toast.error(`Failed to logout: ${err.message}`);
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverConfig.initialized === false) {
|
||||
navigate('/admin/setup');
|
||||
return;
|
||||
}
|
||||
if (!currentUser) {
|
||||
navigate('/admin/auth');
|
||||
return;
|
||||
@@ -32,28 +53,47 @@ export function UserDropdown() {
|
||||
navigate('/admin/auth');
|
||||
return;
|
||||
}
|
||||
}, [currentUser, navigate]);
|
||||
const avatar = currentUser?.avatarUrl ? (
|
||||
<img src={currentUser?.avatarUrl} />
|
||||
) : (
|
||||
<CircleUser size={24} />
|
||||
);
|
||||
}, [currentUser, navigate, serverConfig.initialized]);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className="rounded-full">
|
||||
{avatar}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{currentUser?.name}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Settings</DropdownMenuItem>
|
||||
<DropdownMenuItem>Support</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Logout</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex flex-none items-center justify-between px-4 py-3 flex-nowrap">
|
||||
<div className="flex items-center gap-2 font-medium text-ellipsis break-words overflow-hidden">
|
||||
<Avatar className="w-6 h-6">
|
||||
<AvatarImage src={currentUser?.avatarUrl ?? undefined} />
|
||||
<AvatarFallback>
|
||||
<CircleUser size={24} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{currentUser?.name ? (
|
||||
<span className="text-sm text-nowrap text-ellipsis break-words overflow-hidden">
|
||||
{currentUser?.name}
|
||||
</span>
|
||||
) : (
|
||||
// Fallback to email prefix if name is not available
|
||||
<span className="text-sm">{currentUser?.email.split('@')[0]}</span>
|
||||
)}
|
||||
<span
|
||||
className="rounded p-1 text-xs"
|
||||
style={{
|
||||
backgroundColor: 'rgba(30, 150, 235, 0.20)',
|
||||
color: 'rgba(30, 150, 235, 1)',
|
||||
}}
|
||||
>
|
||||
Admin
|
||||
</span>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="ml-2 p-1 h-6">
|
||||
<MoreVertical size={20} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{currentUser?.name}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleLogout}>Logout</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
|
||||
import type { ModifiedValues } from './index';
|
||||
|
||||
export const ConfirmChanges = ({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
modifiedValues,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
modifiedValues: ModifiedValues[];
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="leading-7">
|
||||
Save Runtime Configurations ?
|
||||
</DialogTitle>
|
||||
<DialogDescription className="leading-6">
|
||||
Are you sure you want to save the following changes?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{modifiedValues.length > 0 ? (
|
||||
<pre className="flex flex-col text-sm bg-zinc-100 gap-1 min-h-[64px] rounded-md p-[12px_16px_16px_12px] mt-2 overflow-hidden">
|
||||
<p>{'{'}</p>
|
||||
{modifiedValues.map(({ id, expiredValue, newValue }) => (
|
||||
<p key={id}>
|
||||
{' '} {id}:{' '}
|
||||
<span
|
||||
className="mr-2 line-through "
|
||||
style={{
|
||||
color: 'rgba(198, 34, 34, 1)',
|
||||
backgroundColor: 'rgba(254, 213, 213, 1)',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(expiredValue)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: 'rgba(20, 147, 67, 1)',
|
||||
backgroundColor: 'rgba(225, 250, 177, 1)',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(newValue)}
|
||||
</span>
|
||||
,
|
||||
</p>
|
||||
))}
|
||||
<p>{'}'}</p>
|
||||
</pre>
|
||||
) : (
|
||||
'There is no change.'
|
||||
)}
|
||||
<DialogFooter>
|
||||
<div className="flex justify-end items-center w-full space-x-4">
|
||||
<Button type="button" onClick={onClose} variant="outline">
|
||||
<span>Cancel</span>
|
||||
</Button>
|
||||
<Button type="button" onClick={onConfirm}>
|
||||
<span>Save</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
193
packages/frontend/admin/src/modules/settings/index.tsx
Normal file
193
packages/frontend/admin/src/modules/settings/index.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import type { RuntimeConfigType } from '@affine/graphql';
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { Layout } from '../layout';
|
||||
import { useNav } from '../nav/context';
|
||||
import { ConfirmChanges } from './confirm-changes';
|
||||
import { RuntimeSettingRow } from './runtime-setting-row';
|
||||
import { useGetServerRuntimeConfig } from './use-get-server-runtime-config';
|
||||
import { useUpdateServerRuntimeConfigs } from './use-update-server-runtime-config';
|
||||
import {
|
||||
formatValue,
|
||||
formatValueForInput,
|
||||
isEqual,
|
||||
renderInput,
|
||||
} from './utils';
|
||||
|
||||
export type ModifiedValues = {
|
||||
id: string;
|
||||
expiredValue: any;
|
||||
newValue: any;
|
||||
};
|
||||
|
||||
export function Settings() {
|
||||
return <Layout content={<SettingsPage />} />;
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const { trigger } = useUpdateServerRuntimeConfigs();
|
||||
const { serverRuntimeConfig } = useGetServerRuntimeConfig();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [configValues, setConfigValues] = useState(
|
||||
serverRuntimeConfig.reduce(
|
||||
(acc, config) => {
|
||||
acc[config.id] = config.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
)
|
||||
);
|
||||
const modifiedValues: ModifiedValues[] = useMemo(() => {
|
||||
return serverRuntimeConfig
|
||||
.filter(config => !isEqual(config.value, configValues[config.id]))
|
||||
.map(config => ({
|
||||
id: config.id,
|
||||
key: config.key,
|
||||
expiredValue: config.value,
|
||||
newValue: configValues[config.id],
|
||||
}));
|
||||
}, [configValues, serverRuntimeConfig]);
|
||||
const handleSave = useCallback(() => {
|
||||
// post value example: { "key1": "newValue1","key2": "newValue2"}
|
||||
const updates: Record<string, any> = {};
|
||||
|
||||
modifiedValues.forEach(item => {
|
||||
if (item.id && item.newValue !== undefined) {
|
||||
updates[item.id] = item.newValue;
|
||||
}
|
||||
});
|
||||
trigger({ updates });
|
||||
}, [modifiedValues, trigger]);
|
||||
|
||||
const disableSave = modifiedValues.length === 0;
|
||||
const onOpen = useCallback(() => setOpen(true), [setOpen]);
|
||||
const onClose = useCallback(() => setOpen(false), [setOpen]);
|
||||
const onConfirm = useCallback(() => {
|
||||
if (disableSave) {
|
||||
return;
|
||||
}
|
||||
handleSave();
|
||||
onClose();
|
||||
}, [disableSave, handleSave, onClose]);
|
||||
return (
|
||||
<div className=" h-screen flex-1 flex-col flex">
|
||||
<div className="flex items-center justify-between px-6 py-3 max-md:ml-9">
|
||||
<div className="text-base font-medium">Settings</div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className="w-7 h-7"
|
||||
variant="ghost"
|
||||
onClick={onOpen}
|
||||
disabled={disableSave}
|
||||
>
|
||||
<CheckIcon size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<AdminPanel
|
||||
configValues={configValues}
|
||||
setConfigValues={setConfigValues}
|
||||
/>
|
||||
<ConfirmChanges
|
||||
modifiedValues={modifiedValues}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const AdminPanel = ({
|
||||
setConfigValues,
|
||||
configValues,
|
||||
}: {
|
||||
setConfigValues: Dispatch<SetStateAction<Record<string, any>>>;
|
||||
configValues: Record<string, any>;
|
||||
}) => {
|
||||
const { configGroup } = useGetServerRuntimeConfig();
|
||||
|
||||
const { currentModule } = useNav();
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(key: string, value: any, type: RuntimeConfigType) => {
|
||||
const newValue = formatValueForInput(value, type);
|
||||
setConfigValues(prevValues => ({
|
||||
...prevValues,
|
||||
[key]: newValue,
|
||||
}));
|
||||
},
|
||||
[setConfigValues]
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea>
|
||||
<div className="flex flex-col h-full gap-3 py-5 px-6 w-full">
|
||||
{configGroup
|
||||
.filter(group => group.moduleName === currentModule)
|
||||
.map(group => {
|
||||
const { moduleName, configs } = group;
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-5"
|
||||
id={moduleName}
|
||||
key={moduleName}
|
||||
>
|
||||
<div className="text-xl font-semibold">{moduleName}</div>
|
||||
{configs?.map((config, index) => {
|
||||
const { id, type, description, updatedAt } = config;
|
||||
const isValueEqual = isEqual(config.value, configValues[id]);
|
||||
const formatServerValue = formatValue(config.value);
|
||||
const formatCurrentValue = formatValue(configValues[id]);
|
||||
return (
|
||||
<div key={id} className="flex flex-col gap-10">
|
||||
{index !== 0 && <Separator />}
|
||||
<RuntimeSettingRow
|
||||
key={id}
|
||||
id={id}
|
||||
description={description}
|
||||
lastUpdatedTime={updatedAt}
|
||||
operation={renderInput(type, configValues[id], value =>
|
||||
handleInputChange(id, value, type)
|
||||
)}
|
||||
>
|
||||
<div style={{ opacity: isValueEqual ? 0 : 1 }}>
|
||||
<span
|
||||
className="line-through"
|
||||
style={{
|
||||
color: 'rgba(198, 34, 34, 1)',
|
||||
backgroundColor: 'rgba(254, 213, 213, 1)',
|
||||
}}
|
||||
>
|
||||
{formatServerValue}
|
||||
</span>{' '}
|
||||
=>{' '}
|
||||
<span
|
||||
style={{
|
||||
color: 'rgba(20, 147, 67, 1)',
|
||||
backgroundColor: 'rgba(225, 250, 177, 1)',
|
||||
}}
|
||||
>
|
||||
{formatCurrentValue}
|
||||
</span>
|
||||
</div>
|
||||
</RuntimeSettingRow>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
export { Settings as Component };
|
||||
@@ -0,0 +1,39 @@
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
export const RuntimeSettingRow = ({
|
||||
id,
|
||||
description,
|
||||
lastUpdatedTime,
|
||||
operation,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
description: string;
|
||||
lastUpdatedTime: string;
|
||||
operation: ReactNode;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const formatTime = new Date(lastUpdatedTime).toLocaleString();
|
||||
return (
|
||||
<div
|
||||
className="flex justify-between flex-grow overflow-y-auto space-y-[10px] gap-5"
|
||||
id={id}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-base font-bold">{description}</div>
|
||||
<div className="">
|
||||
<code className="text-xs bg-zinc-100 text-gray-500 px-[4px] py-[2px] rounded">
|
||||
{id}
|
||||
</code>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
last updated at: {formatTime}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 mr-1">
|
||||
{operation}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useQuery } from '@affine/core/hooks/use-query';
|
||||
import { getServerRuntimeConfigQuery } from '@affine/graphql';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const useGetServerRuntimeConfig = () => {
|
||||
const { data } = useQuery({
|
||||
query: getServerRuntimeConfigQuery,
|
||||
});
|
||||
|
||||
const serverRuntimeConfig = useMemo(
|
||||
() =>
|
||||
data?.serverRuntimeConfig.sort((a, b) => a.id.localeCompare(b.id)) ?? [],
|
||||
[data]
|
||||
);
|
||||
|
||||
// collect all the modules and config keys in each module
|
||||
const moduleList = useMemo(() => {
|
||||
const moduleMap: { [key: string]: string[] } = {};
|
||||
|
||||
serverRuntimeConfig.forEach(config => {
|
||||
if (!moduleMap[config.module]) {
|
||||
moduleMap[config.module] = [];
|
||||
}
|
||||
moduleMap[config.module].push(config.key);
|
||||
});
|
||||
|
||||
return Object.keys(moduleMap)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map(moduleName => ({
|
||||
moduleName,
|
||||
keys: moduleMap[moduleName].sort((a, b) => a.localeCompare(b)),
|
||||
}));
|
||||
}, [serverRuntimeConfig]);
|
||||
|
||||
// group config by module name
|
||||
const configGroup = useMemo(() => {
|
||||
const configMap = new Map<string, typeof serverRuntimeConfig>();
|
||||
|
||||
serverRuntimeConfig.forEach(config => {
|
||||
if (!configMap.has(config.module)) {
|
||||
configMap.set(config.module, []);
|
||||
}
|
||||
configMap.get(config.module)?.push(config);
|
||||
});
|
||||
|
||||
return Array.from(configMap.entries()).map(([moduleName, configs]) => ({
|
||||
moduleName,
|
||||
configs,
|
||||
}));
|
||||
}, [serverRuntimeConfig]);
|
||||
|
||||
return {
|
||||
serverRuntimeConfig,
|
||||
moduleList,
|
||||
configGroup,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { notify } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import {
|
||||
useMutateQueryResource,
|
||||
useMutation,
|
||||
} from '@affine/core/hooks/use-mutation';
|
||||
import {
|
||||
getServerRuntimeConfigQuery,
|
||||
updateServerRuntimeConfigsMutation,
|
||||
} from '@affine/graphql';
|
||||
|
||||
export const useUpdateServerRuntimeConfigs = () => {
|
||||
const { trigger, isMutating } = useMutation({
|
||||
mutation: updateServerRuntimeConfigsMutation,
|
||||
});
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
return {
|
||||
trigger: useAsyncCallback(
|
||||
async (values: any) => {
|
||||
try {
|
||||
await trigger(values);
|
||||
await revalidate(getServerRuntimeConfigQuery);
|
||||
notify.success({
|
||||
title: 'Saved successfully',
|
||||
message: 'Runtime configurations have been saved successfully.',
|
||||
});
|
||||
} catch (e) {
|
||||
notify.error({
|
||||
title: 'Failed to save',
|
||||
message:
|
||||
'Failed to save runtime configurations, please try again later.',
|
||||
});
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
[revalidate, trigger]
|
||||
),
|
||||
isMutating,
|
||||
};
|
||||
};
|
||||
73
packages/frontend/admin/src/modules/settings/utils.tsx
Normal file
73
packages/frontend/admin/src/modules/settings/utils.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import { Switch } from '@affine/admin/components/ui/switch';
|
||||
import type { RuntimeConfigType } from '@affine/graphql';
|
||||
|
||||
export const renderInput = (
|
||||
type: RuntimeConfigType,
|
||||
value: any,
|
||||
onChange: (value?: any) => void
|
||||
) => {
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
const handleSwitchChange = (checked: boolean) => {
|
||||
onChange(checked);
|
||||
};
|
||||
switch (type) {
|
||||
case 'Boolean':
|
||||
return <Switch checked={value} onCheckedChange={handleSwitchChange} />;
|
||||
case 'String':
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
minLength={1}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
);
|
||||
case 'Number':
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<Input type="number" value={value} onChange={handleInputChange} />
|
||||
</div>
|
||||
);
|
||||
// TODO(@JimmFly): add more types
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const isEqual = (a: any, b: any) => {
|
||||
if (typeof a !== typeof b) return false;
|
||||
if (typeof a === 'object') return JSON.stringify(a) === JSON.stringify(b);
|
||||
return a === b;
|
||||
};
|
||||
|
||||
export const formatValue = (value: any) => {
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
export const formatValueForInput = (value: any, type: RuntimeConfigType) => {
|
||||
let newValue = null;
|
||||
switch (type) {
|
||||
case 'Boolean':
|
||||
newValue = !!value;
|
||||
break;
|
||||
case 'String':
|
||||
newValue = value;
|
||||
break;
|
||||
case 'Number':
|
||||
newValue = Number(value);
|
||||
break;
|
||||
case 'Array':
|
||||
newValue = value.split(',');
|
||||
break;
|
||||
case 'Object':
|
||||
newValue = JSON.parse(value);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return newValue;
|
||||
};
|
||||
115
packages/frontend/admin/src/modules/setup/create-admin.tsx
Normal file
115
packages/frontend/admin/src/modules/setup/create-admin.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import { Label } from '@affine/admin/components/ui/label';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
type CreateAdminProps = {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
invalidEmail: boolean;
|
||||
invalidPassword: boolean;
|
||||
passwordLimits: {
|
||||
minLength: number;
|
||||
maxLength: number;
|
||||
};
|
||||
onNameChange: (name: string) => void;
|
||||
onEmailChange: (email: string) => void;
|
||||
onPasswordChange: (password: string) => void;
|
||||
};
|
||||
|
||||
export const CreateAdmin = ({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
invalidEmail,
|
||||
invalidPassword,
|
||||
passwordLimits,
|
||||
onNameChange,
|
||||
onEmailChange,
|
||||
onPasswordChange,
|
||||
}: CreateAdminProps) => {
|
||||
const handleNameChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onNameChange(event.target.value);
|
||||
},
|
||||
[onNameChange]
|
||||
);
|
||||
const handleEmailChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onEmailChange(event.target.value);
|
||||
},
|
||||
[onEmailChange]
|
||||
);
|
||||
|
||||
const handlePasswordChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onPasswordChange(event.target.value);
|
||||
},
|
||||
[onPasswordChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full mt-24 max-lg:items-center max-lg:mt-16 max-md:mt-5 lg:pl-0">
|
||||
<div className="flex flex-col pl-1 max-lg:p-4 max-w-96 mb-5">
|
||||
<div className="flex flex-col mb-16 max-sm:mb-6">
|
||||
<h1 className="text-lg font-semibold">
|
||||
Create Administrator Account
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This account can also be used to log in as an AFFiNE user.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-9">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 relative">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
required
|
||||
/>
|
||||
<p
|
||||
className={`absolute text-sm text-red-500 -bottom-6 ${invalidEmail ? '' : 'opacity-0 pointer-events-none'}`}
|
||||
>
|
||||
Invalid email address.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
min={passwordLimits.minLength}
|
||||
max={passwordLimits.maxLength}
|
||||
required
|
||||
/>
|
||||
<p
|
||||
className={`text-sm text-muted-foreground ${invalidPassword && 'text-red-500'}`}
|
||||
>
|
||||
{invalidPassword ? 'Invalid password. ' : ''}Please enter{' '}
|
||||
{String(passwordLimits.minLength)}-
|
||||
{String(passwordLimits.maxLength)} digit password, it is
|
||||
recommended to include 2+ of: uppercase, lowercase, numbers,
|
||||
symbols.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
230
packages/frontend/admin/src/modules/setup/form.tsx
Normal file
230
packages/frontend/admin/src/modules/setup/form.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import type { CarouselApi } from '@affine/admin/components/ui/carousel';
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from '@affine/admin/components/ui/carousel';
|
||||
import { validateEmailAndPassword } from '@affine/admin/utils';
|
||||
import { useMutateQueryResource } from '@affine/core/hooks/use-mutation';
|
||||
import { serverConfigQuery } from '@affine/graphql';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useServerConfig } from '../common';
|
||||
import { CreateAdmin } from './create-admin';
|
||||
|
||||
export enum CarouselSteps {
|
||||
Welcome = 0,
|
||||
CreateAdmin,
|
||||
SettingsDone,
|
||||
}
|
||||
|
||||
const Welcome = () => {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col h-full w-full mt-60 max-lg:items-center max-lg:mt-16"
|
||||
style={{ minHeight: '300px' }}
|
||||
>
|
||||
<h1 className="text-5xl font-extrabold max-lg:text-3xl max-lg:font-bold">
|
||||
Welcome to AFFiNE
|
||||
</h1>
|
||||
<p className="mt-5 font-semibold text-xl max-lg:px-4 max-lg:text-lg">
|
||||
Configure your Self Host AFFiNE with a few simple settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsDone = () => {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col h-full w-full mt-60 max-lg:items-center max-lg:mt-16"
|
||||
style={{ minHeight: '300px' }}
|
||||
>
|
||||
<h1 className="text-5xl font-extrabold max-lg:text-3xl max-lg:font-bold">
|
||||
All Settings Done
|
||||
</h1>
|
||||
<p className="mt-5 font-semibold text-xl max-lg:px-4 max-lg:text-lg">
|
||||
AFFiNE is ready to use.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CarouselItemElements = {
|
||||
[CarouselSteps.Welcome]: Welcome,
|
||||
[CarouselSteps.CreateAdmin]: CreateAdmin,
|
||||
[CarouselSteps.SettingsDone]: SettingsDone,
|
||||
};
|
||||
|
||||
export const Form = () => {
|
||||
const [api, setApi] = useState<CarouselApi>();
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [nameValue, setNameValue] = useState('');
|
||||
const [emailValue, setEmailValue] = useState('');
|
||||
const [passwordValue, setPasswordValue] = useState('');
|
||||
const [invalidEmail, setInvalidEmail] = useState(false);
|
||||
const [invalidPassword, setInvalidPassword] = useState(false);
|
||||
|
||||
const serverConfig = useServerConfig();
|
||||
const passwordLimits = serverConfig.credentialsRequirement.password;
|
||||
|
||||
const isCreateAdminStep = current - 1 === CarouselSteps.CreateAdmin;
|
||||
|
||||
const disableContinue =
|
||||
(!nameValue || !emailValue || !passwordValue) && isCreateAdminStep;
|
||||
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCount(api.scrollSnapList().length);
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
|
||||
api.on('select', () => {
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
});
|
||||
}, [api, serverConfig.initialized, navigate]);
|
||||
|
||||
const createAdmin = useCallback(async () => {
|
||||
try {
|
||||
const createResponse = await fetch('/api/setup/create-admin-user', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: emailValue,
|
||||
password: passwordValue,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!createResponse.ok) {
|
||||
const errorData = await createResponse.json();
|
||||
throw new Error(errorData.message || 'Failed to create admin');
|
||||
}
|
||||
|
||||
await createResponse.json();
|
||||
await revalidate(serverConfigQuery);
|
||||
toast.success('Admin account created successfully.');
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
}, [emailValue, passwordValue, revalidate]);
|
||||
|
||||
const onNext = useCallback(async () => {
|
||||
if (isCreateAdminStep) {
|
||||
if (
|
||||
!validateEmailAndPassword(
|
||||
emailValue,
|
||||
passwordValue,
|
||||
passwordLimits,
|
||||
setInvalidEmail,
|
||||
setInvalidPassword
|
||||
)
|
||||
) {
|
||||
return;
|
||||
} else {
|
||||
try {
|
||||
await createAdmin();
|
||||
setInvalidEmail(false);
|
||||
setInvalidPassword(false);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setInvalidEmail(true);
|
||||
setInvalidPassword(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (current === count) {
|
||||
return navigate('/', { replace: true });
|
||||
}
|
||||
|
||||
api?.scrollNext();
|
||||
}, [
|
||||
api,
|
||||
count,
|
||||
createAdmin,
|
||||
current,
|
||||
emailValue,
|
||||
isCreateAdminStep,
|
||||
navigate,
|
||||
passwordLimits,
|
||||
passwordValue,
|
||||
]);
|
||||
|
||||
const onPrevious = useCallback(() => {
|
||||
if (current === count) {
|
||||
if (serverConfig.initialized === true) {
|
||||
return navigate('/admin', { replace: true });
|
||||
}
|
||||
toast.error('Goto Admin Panel failed, please try again.');
|
||||
return;
|
||||
}
|
||||
api?.scrollPrev();
|
||||
}, [api, count, current, serverConfig.initialized, navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-between h-full w-full lg:pl-36 max-lg:items-center ">
|
||||
<Carousel
|
||||
setApi={setApi}
|
||||
className=" h-full w-full"
|
||||
opts={{ watchDrag: false }}
|
||||
>
|
||||
<CarouselContent>
|
||||
{Object.entries(CarouselItemElements).map(([key, Element]) => (
|
||||
<CarouselItem key={key}>
|
||||
<Element
|
||||
name={nameValue}
|
||||
email={emailValue}
|
||||
password={passwordValue}
|
||||
invalidEmail={invalidEmail}
|
||||
invalidPassword={invalidPassword}
|
||||
passwordLimits={passwordLimits}
|
||||
onNameChange={setNameValue}
|
||||
onEmailChange={setEmailValue}
|
||||
onPasswordChange={setPasswordValue}
|
||||
/>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
<div>
|
||||
{current > 1 && (
|
||||
<Button className="mr-3" onClick={onPrevious} variant="outline">
|
||||
{current === count ? 'Goto Admin Panel' : 'Back'}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onNext} disabled={disableContinue}>
|
||||
{current === count ? 'Open AFFiNE' : 'Continue'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="py-2 px-0 text-sm mt-16 max-lg:mt-5 relative">
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`inline-block w-16 h-1 rounded mr-1 ${
|
||||
index <= current - 1
|
||||
? 'bg-primary'
|
||||
: 'bg-muted-foreground opacity-20'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user