feat(server): with api server, service-lize (#807)
Co-authored-by: Neko Ayaka <neko@ayaka.moe> Co-authored-by: Lovehsigure_520 <1260907335@qq.com>
This commit is contained in:
co-authored by
Neko Ayaka
Lovehsigure_520
parent
dd46b8deff
commit
d064a959cf
@@ -0,0 +1,7 @@
|
||||
DATABASE_URL=""
|
||||
|
||||
AUTH_GOOGLE_CLIENT_ID=""
|
||||
AUTH_GOOGLE_CLIENT_SECRET=""
|
||||
|
||||
AUTH_GITHUB_CLIENT_ID=""
|
||||
AUTH_GITHUB_CLIENT_SECRET=""
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/server apps/server
|
||||
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["pnpm", "-F", "@proj-airi/api-server", "start"]
|
||||
@@ -0,0 +1,33 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: airi-postgres
|
||||
environment:
|
||||
- POSTGRES_DB=airi
|
||||
- POSTGRES_USER=airi
|
||||
- POSTGRES_PASSWORD=airi
|
||||
ports:
|
||||
- '5432:5432'
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
volumes:
|
||||
- ${serviceName}_data:/var/lib/postgresql/data
|
||||
|
||||
server:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: apps/server/Dockerfile
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- '3000:3000'
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
driver: local
|
||||
@@ -0,0 +1,10 @@
|
||||
import process from 'node:process'
|
||||
|
||||
export default {
|
||||
schema: './src/schemas/**/*.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
CREATE TABLE "account" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"account_id" text NOT NULL,
|
||||
"provider_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"access_token" text,
|
||||
"refresh_token" text,
|
||||
"id_token" text,
|
||||
"access_token_expires_at" timestamp,
|
||||
"refresh_token_expires_at" timestamp,
|
||||
"scope" text,
|
||||
"password" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "session" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp NOT NULL,
|
||||
"ip_address" text,
|
||||
"user_agent" text,
|
||||
"user_id" text NOT NULL,
|
||||
CONSTRAINT "session_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"email_verified" boolean DEFAULT false NOT NULL,
|
||||
"image" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "user_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "verification" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"identifier" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");
|
||||
@@ -0,0 +1,374 @@
|
||||
{
|
||||
"id": "b19a2fb9-f374-4190-808b-0988677f5823",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.account": {
|
||||
"name": "account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"account_userId_idx": {
|
||||
"name": "account_userId_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"session_userId_idx": {
|
||||
"name": "session_userId_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification": {
|
||||
"name": "verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"verification_identifier_idx": {
|
||||
"name": "verification_identifier_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "identifier",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1766665325052,
|
||||
"tag": "0000_steep_gamora",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@proj-airi/api-server",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"apply:env": "dotenvx run -f .env -f .env.local --overload --ignore=MISSING_ENV_FILE",
|
||||
"auth:generate": "pnpm run apply:env -- better-auth generate --config src/scripts/auth.ts --output src/schemas/auth.ts -y",
|
||||
"dev": "pnpm run apply:env -- tsx --watch src/app.ts",
|
||||
"start": "tsx src/app.ts",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:push": "pnpm run apply:env -- drizzle-kit push"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dotenvx/dotenvx": "^1.51.1",
|
||||
"@guiiai/logg": "catalog:",
|
||||
"@hono/node-server": "^1.19.6",
|
||||
"better-auth": "^1.4.5",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"hono": "^4.10.7",
|
||||
"injeca": "catalog:",
|
||||
"postgres": "^3.4.7",
|
||||
"tsx": "^4.21.0",
|
||||
"valibot": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@better-auth/cli": "^1.4.5",
|
||||
"drizzle-kit": "^0.31.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[build]
|
||||
builder = "DOCKERFILE"
|
||||
dockerfilePath = "apps/server/Dockerfile"
|
||||
watchPatterns = [
|
||||
"apps/server/**",
|
||||
"packages/**",
|
||||
"pnpm-lock.yaml"
|
||||
]
|
||||
|
||||
[deploy]
|
||||
startCommand = "pnpm -F @proj-airi/api-server start"
|
||||
@@ -0,0 +1,99 @@
|
||||
import process, { exit } from 'node:process'
|
||||
|
||||
import { initLogger, LoggerFormat, LoggerLevel, useLogger } from '@guiiai/logg'
|
||||
import { serve } from '@hono/node-server'
|
||||
import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import { logger as honoLogger } from 'hono/logger'
|
||||
import { injeca } from 'injeca'
|
||||
|
||||
import { createAuth } from './services/auth'
|
||||
import { createDrizzle } from './services/db'
|
||||
import { parsedEnv } from './services/env'
|
||||
import { getTrustedOrigin } from './utils/origin'
|
||||
|
||||
async function createApp() {
|
||||
initLogger(LoggerLevel.Debug, LoggerFormat.Pretty)
|
||||
|
||||
const resolved = await injeca.resolve({ parsedEnv })
|
||||
|
||||
const logger = useLogger('app').useGlobalConfig()
|
||||
const db = createDrizzle(resolved.parsedEnv.DATABASE_URL)
|
||||
const auth = createAuth(db, resolved.parsedEnv)
|
||||
|
||||
db.execute('SELECT 1')
|
||||
.then(() => {
|
||||
logger.log('Connected to database')
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.withError(err).error('Failed to connect to database')
|
||||
exit(1)
|
||||
})
|
||||
|
||||
const app = new Hono<{
|
||||
Variables: {
|
||||
user: typeof auth.$Infer.Session.user | null
|
||||
session: typeof auth.$Infer.Session.session | null
|
||||
}
|
||||
}>()
|
||||
|
||||
app.use(
|
||||
'/api/auth/*', // or replace with "*" to enable cors for all routes
|
||||
cors({
|
||||
origin(origin: string) {
|
||||
return getTrustedOrigin(origin)
|
||||
},
|
||||
credentials: true,
|
||||
}),
|
||||
)
|
||||
|
||||
app.use(honoLogger())
|
||||
|
||||
app.use('*', async (c, next) => {
|
||||
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
||||
|
||||
if (!session) {
|
||||
c.set('user', null)
|
||||
c.set('session', null)
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
c.set('user', session.user)
|
||||
c.set('session', session.session)
|
||||
|
||||
await next()
|
||||
})
|
||||
|
||||
app.get('/session', (c) => {
|
||||
const session = c.get('session')
|
||||
const user = c.get('user')
|
||||
|
||||
if (!user)
|
||||
return c.body(null, 401)
|
||||
|
||||
return c.json({
|
||||
session,
|
||||
user,
|
||||
})
|
||||
})
|
||||
|
||||
// NOTICE: required by better-auth
|
||||
app.on(['POST', 'GET'], '/api/auth/*', (c) => {
|
||||
return auth.handler(c.req.raw)
|
||||
})
|
||||
|
||||
logger.withFields({ port: 3000 }).log('Server started')
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// eslint-disable-next-line antfu/no-top-level-await
|
||||
serve(await createApp())
|
||||
|
||||
function handleError(error: unknown, type: string) {
|
||||
useLogger().withError(error).error(type)
|
||||
}
|
||||
|
||||
process.on('uncaughtException', error => handleError(error, 'Uncaught exception'))
|
||||
process.on('unhandledRejection', error => handleError(error, 'Unhandled rejection'))
|
||||
@@ -0,0 +1,93 @@
|
||||
import { relations } from 'drizzle-orm'
|
||||
import { boolean, index, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
|
||||
|
||||
export const user = pgTable('user', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
email: text('email').notNull().unique(),
|
||||
emailVerified: boolean('email_verified').default(false).notNull(),
|
||||
image: text('image'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
})
|
||||
|
||||
export const session = pgTable(
|
||||
'session',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
token: text('token').notNull().unique(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
table => [index('session_userId_idx').on(table.userId)],
|
||||
)
|
||||
|
||||
export const account = pgTable(
|
||||
'account',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
accountId: text('account_id').notNull(),
|
||||
providerId: text('provider_id').notNull(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
idToken: text('id_token'),
|
||||
accessTokenExpiresAt: timestamp('access_token_expires_at'),
|
||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
|
||||
scope: text('scope'),
|
||||
password: text('password'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
table => [index('account_userId_idx').on(table.userId)],
|
||||
)
|
||||
|
||||
export const verification = pgTable(
|
||||
'verification',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
identifier: text('identifier').notNull(),
|
||||
value: text('value').notNull(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
table => [index('verification_identifier_idx').on(table.identifier)],
|
||||
)
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
}))
|
||||
|
||||
export const sessionRelations = relations(session, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [session.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}))
|
||||
|
||||
export const accountRelations = relations(account, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [account.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}))
|
||||
@@ -0,0 +1,8 @@
|
||||
import process from 'node:process'
|
||||
|
||||
import { createAuth } from '../services/auth'
|
||||
import { createDrizzle } from '../services/db'
|
||||
import { parseEnv } from '../services/env'
|
||||
|
||||
const env = parseEnv(process.env)
|
||||
export default createAuth(createDrizzle(env.DATABASE_URL), env)
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Database } from './db'
|
||||
import type { Env } from './env'
|
||||
|
||||
import process from 'node:process'
|
||||
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||
import { bearer } from 'better-auth/plugins'
|
||||
|
||||
import * as authSchema from '../schemas/auth'
|
||||
|
||||
export function createAuth(db: Database, env: Env) {
|
||||
return betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: 'pg',
|
||||
schema: {
|
||||
...authSchema,
|
||||
},
|
||||
}),
|
||||
|
||||
plugins: [
|
||||
bearer(),
|
||||
],
|
||||
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
baseURL: process.env.API_SERVER_URL || 'http://localhost:3000',
|
||||
trustedOrigins: ['*'],
|
||||
|
||||
// To skip state-mismatch errors
|
||||
// https://github.com/better-auth/better-auth/issues/4969#issuecomment-3397804378
|
||||
advanced: {
|
||||
defaultCookieAttributes: {
|
||||
sameSite: 'None', // this enables cross-site cookies
|
||||
secure: true, // required for SameSite=None
|
||||
},
|
||||
},
|
||||
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: env.AUTH_GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.AUTH_GOOGLE_CLIENT_SECRET,
|
||||
},
|
||||
github: {
|
||||
clientId: env.AUTH_GITHUB_CLIENT_ID,
|
||||
clientSecret: env.AUTH_GITHUB_CLIENT_SECRET,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import postgres from 'postgres'
|
||||
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
|
||||
export type Database = ReturnType<typeof createDrizzle>
|
||||
|
||||
export function createDrizzle(dsn: string) {
|
||||
return drizzle(postgres(dsn))
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { InferOutput } from 'valibot'
|
||||
|
||||
import { env, exit } from 'node:process'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { injeca } from 'injeca'
|
||||
import { nonEmpty, object, parse, pipe, string } from 'valibot'
|
||||
|
||||
const EnvSchema = object({
|
||||
DATABASE_URL: pipe(string(), nonEmpty('DATABASE_URL is required')),
|
||||
AUTH_GOOGLE_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_ID is required')),
|
||||
AUTH_GOOGLE_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_SECRET is required')),
|
||||
AUTH_GITHUB_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_ID is required')),
|
||||
AUTH_GITHUB_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_SECRET is required')),
|
||||
})
|
||||
|
||||
export type Env = InferOutput<typeof EnvSchema>
|
||||
|
||||
export function parseEnv(inputEnv: Record<string, string> | typeof env): Env {
|
||||
try {
|
||||
return parse(EnvSchema, inputEnv)
|
||||
}
|
||||
catch (err) {
|
||||
useLogger().withError(err).error('Invalid environment variables')
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
export const parsedEnv = injeca.provide('env', () => parseEnv(env))
|
||||
@@ -0,0 +1,20 @@
|
||||
export function getTrustedOrigin(origin: string): string {
|
||||
// 1. Allow Dev (Localhost with any port)
|
||||
if (!origin || origin.startsWith('http://localhost:')) {
|
||||
return origin
|
||||
}
|
||||
|
||||
// 2. Allow Production (Exact Match)
|
||||
if (origin === 'https://airi.moeru.ai') {
|
||||
return origin
|
||||
}
|
||||
|
||||
// 3. Allow Dynamic Subdomains (Strict Regex)
|
||||
// Matches: https://foo.kwaa.workers.dev
|
||||
if (/^https:\/\/.*\.kwaa\.workers\.dev$/.test(origin)) {
|
||||
return origin
|
||||
}
|
||||
|
||||
// Default: Block
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ESNext",
|
||||
"jsx": "preserve",
|
||||
"lib": [
|
||||
"ESNext"
|
||||
],
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"types": [
|
||||
"vitest",
|
||||
"node"
|
||||
],
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -54,7 +54,8 @@
|
||||
"@xsai/stream-transcription": "0.4.0-beta.8",
|
||||
"@xsai/utils-chat": "catalog:",
|
||||
"animejs": "^4.2.2",
|
||||
"colorjs.io": "^0.6.0",
|
||||
"better-auth": "^1.4.5",
|
||||
"colorjs.io": "^0.5.2",
|
||||
"culori": "^4.0.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.1",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ofetch } from 'ofetch'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { API_SERVER_URL } from './auth'
|
||||
|
||||
export function doRequest(url: string, options: RequestInit = {}) {
|
||||
const authStore = useAuthStore()
|
||||
return ofetch(url, {
|
||||
baseURL: API_SERVER_URL,
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${authStore.authToken}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createAuthClient } from 'better-auth/vue'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
export const API_SERVER_URL = import.meta.env.VITE_SERVER_URL || 'https://airi-api.moeru.ai'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: API_SERVER_URL,
|
||||
|
||||
credentials: 'include',
|
||||
fetchOptions: {
|
||||
auth: {
|
||||
type: 'Bearer',
|
||||
token: () => authStore.authToken,
|
||||
},
|
||||
onSuccess: (ctx) => {
|
||||
const newToken = ctx.response.headers.get('set-auth-token')
|
||||
if (newToken) {
|
||||
authStore.authToken = newToken
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export async function fetchSession() {
|
||||
const { data } = await authClient.getSession()
|
||||
if (data) {
|
||||
authStore.user = data.user
|
||||
authStore.session = data.session
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function listSessions() {
|
||||
return await authClient.listSessions()
|
||||
}
|
||||
|
||||
export async function signOut() {
|
||||
await authClient.signOut()
|
||||
|
||||
authStore.user = undefined
|
||||
authStore.session = undefined
|
||||
authStore.authToken = ''
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: plain
|
||||
</route>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { authClient, fetchSession } from '../../composables/auth'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const isLoading = ref(false)
|
||||
|
||||
function signIn(provider: 'google' | 'github') {
|
||||
isLoading.value = true
|
||||
authClient.signIn.social({
|
||||
provider,
|
||||
callbackURL: window.location.origin,
|
||||
}, {
|
||||
onSuccess: (ctx: any) => {
|
||||
const authToken = ctx.response.headers.get('set-auth-token') // get the token from the response headers
|
||||
if (authToken) {
|
||||
useAuthStore().authToken = authToken
|
||||
}
|
||||
},
|
||||
onError: (ctx: any) => {
|
||||
isLoading.value = false
|
||||
toast.error(ctx.error.message || 'Failed to sign in')
|
||||
},
|
||||
}).catch((error: any) => {
|
||||
isLoading.value = false
|
||||
toast.error(error instanceof Error ? error.message : 'An unknown error occurred')
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSession()
|
||||
.then((authenticated) => {
|
||||
if (authenticated) {
|
||||
router.replace('/')
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen w-full flex items-center justify-center overflow-hidden bg-[#050505] text-white font-sans selection:bg-purple-500/30">
|
||||
<!-- Animated Background Elements -->
|
||||
<div class="pointer-events-none absolute inset-0 z-0 overflow-hidden">
|
||||
<div class="animate-pulse-slow absolute left-[-10%] top-[-20%] h-[600px] w-[600px] rounded-full bg-purple-600/15 blur-[120px]" />
|
||||
<div class="animate-pulse-slow absolute bottom-[-20%] right-[-10%] h-[600px] w-[600px] rounded-full bg-blue-600/15 blur-[120px] delay-1000" />
|
||||
<div class="absolute left-[40%] top-[40%] h-[300px] w-[300px] rounded-full bg-pink-500/10 blur-[100px]" />
|
||||
</div>
|
||||
|
||||
<!-- Grid Pattern Overlay -->
|
||||
<div class="[mask-image:radial-gradient(ellipse_60%_50%_at_50%_50%,#000_70%,transparent_100%)] absolute inset-0 z-0 bg-[linear-gradient(to_right,#ffffff05_1px,transparent_1px),linear-gradient(to_bottom,#ffffff05_1px,transparent_1px)] bg-[size:32px_32px]" />
|
||||
|
||||
<!-- Main Card -->
|
||||
<div class="relative z-10 max-w-md w-full p-6 sm:p-8">
|
||||
<div class="relative overflow-hidden border border-white/10 rounded-3xl bg-black/60 shadow-2xl backdrop-blur-2xl transition-all duration-500 hover:border-white/20 hover:shadow-2xl hover:shadow-purple-500/10">
|
||||
<!-- Shine effect -->
|
||||
<div class="pointer-events-none absolute inset-0 from-white/5 to-transparent bg-gradient-to-br" />
|
||||
|
||||
<div class="relative flex flex-col items-center p-8">
|
||||
<!-- Logo / Icon -->
|
||||
<div class="group mb-8 h-20 w-20 flex items-center justify-center rounded-2xl bg-gradient-to-tr shadow-lg shadow-violet-500/25">
|
||||
<div class="i-solar-gamepad-bold text-4xl text-white transition-transform duration-300 group-hover:rotate-[-10deg] group-hover:scale-110" />
|
||||
</div>
|
||||
|
||||
<h1 class="mb-3 text-3xl text-white font-bold tracking-tight sm:text-4xl">
|
||||
A I R I
|
||||
</h1>
|
||||
<p class="mb-10 text-center text-sm text-gray-400 leading-relaxed">
|
||||
Dive into the virtual dimension. <br>
|
||||
Connect with your favorite characters today.
|
||||
</p>
|
||||
|
||||
<div class="w-full flex flex-col gap-4">
|
||||
<button
|
||||
class="group relative w-full flex items-center justify-center gap-3 rounded-xl bg-white px-4 py-3.5 text-black font-bold transition-all active:scale-[0.98] hover:scale-[1.02] disabled:cursor-not-allowed hover:bg-gray-100 disabled:opacity-70"
|
||||
:disabled="isLoading"
|
||||
@click="signIn('google')"
|
||||
>
|
||||
<div class="i-simple-icons-google text-xl transition-transform group-hover:scale-110" />
|
||||
<span>Sign in with Google</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="group relative w-full flex items-center justify-center gap-3 border border-white/10 rounded-xl bg-white/5 px-4 py-3.5 text-white font-bold transition-all active:scale-[0.98] hover:scale-[1.02] disabled:cursor-not-allowed hover:bg-white/10 disabled:opacity-70"
|
||||
:disabled="isLoading"
|
||||
@click="signIn('github')"
|
||||
>
|
||||
<div class="i-simple-icons-github text-xl transition-transform group-hover:scale-110" />
|
||||
<span>Sign in with GitHub</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex items-center justify-center gap-6 text-xs text-gray-500 font-medium">
|
||||
<a href="#" class="transition-colors hover:text-white">Terms</a>
|
||||
<span class="h-1 w-1 rounded-full bg-gray-700" />
|
||||
<a href="#" class="transition-colors hover:text-white">Privacy</a>
|
||||
<span class="h-1 w-1 rounded-full bg-gray-700" />
|
||||
<a href="#" class="transition-colors hover:text-white">Help</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.animate-pulse-slow {
|
||||
animation: pulse 8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: false
|
||||
</route>
|
||||
@@ -23,6 +23,8 @@ import { breakpointsTailwind, useBreakpoints, useMouse } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { fetchSession } from '../composables/auth'
|
||||
|
||||
const paused = ref(false)
|
||||
|
||||
function handleSettingsOpen(open: boolean) {
|
||||
@@ -167,6 +169,12 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// TODO: move this to pinia store with `initialize(...)` action.
|
||||
// Fetch session, ignore errors
|
||||
fetchSession().catch(() => {})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Session, User } from 'better-auth'
|
||||
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const authToken = useLocalStorage('auth/token', '')
|
||||
const user = ref<User>()
|
||||
const session = ref<Session>()
|
||||
const isAuthenticated = computed(() => !!user.value && !!session.value)
|
||||
|
||||
// TODO: include fetchSession here for pulling and updating better-auth session with initialize(...) action
|
||||
|
||||
return {
|
||||
authToken,
|
||||
user,
|
||||
session,
|
||||
isAuthenticated,
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import HeaderAvatar from './HeaderAvatar.vue'
|
||||
import HeaderLink from './HeaderLink.vue'
|
||||
import ActionAbout from './InteractiveArea/Actions/About.vue'
|
||||
</script>
|
||||
@@ -10,15 +9,7 @@ import ActionAbout from './InteractiveArea/Actions/About.vue'
|
||||
<HeaderLink />
|
||||
<div flex items-center gap-2>
|
||||
<ActionAbout />
|
||||
<RouterLink
|
||||
border="2 solid neutral-100/60 dark:neutral-800/30"
|
||||
bg="neutral-50/70 dark:neutral-800/70"
|
||||
w-fit flex items-center justify-center rounded-xl p-2 backdrop-blur-md
|
||||
title="Settings"
|
||||
to="/settings"
|
||||
>
|
||||
<div i-solar:settings-minimalistic-bold-duotone size-5 text="neutral-500 dark:neutral-400" />
|
||||
</RouterLink>
|
||||
<HeaderAvatar />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { listSessions, signOut } from '../../composables/auth'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { isAuthenticated, user } = storeToRefs(authStore)
|
||||
|
||||
const userName = computed(() => user.value?.name)
|
||||
const userAvatar = computed(() => user.value?.image)
|
||||
const showDropdown = ref(false)
|
||||
const dropdownRef = ref(null)
|
||||
|
||||
onClickOutside(dropdownRef, () => {
|
||||
showDropdown.value = false
|
||||
})
|
||||
|
||||
function handleLogout() {
|
||||
signOut()
|
||||
}
|
||||
|
||||
async function handleListSessions() {
|
||||
try {
|
||||
const { data: sessions } = await listSessions()
|
||||
if (sessions) {
|
||||
toast.success(`You have ${sessions.length} active sessions.`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'An unknown error occurred')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex items-center gap-2>
|
||||
<!-- Non-authenticated: Settings & Login -->
|
||||
<!-- NOTICE: The avatar is stored in the localstorage, it will be shown at the first time of the page load, so we do not need the skeleton loading here -->
|
||||
<template v-if="!isAuthenticated">
|
||||
<RouterLink
|
||||
border="2 solid neutral-100/60 dark:neutral-800/30"
|
||||
bg="neutral-50/70 dark:neutral-800/70"
|
||||
w-fit flex items-center justify-center rounded-xl p-2 backdrop-blur-md
|
||||
title="Settings"
|
||||
to="/settings"
|
||||
>
|
||||
<div i-solar:settings-minimalistic-bold-duotone size-5 text="neutral-500 dark:neutral-400" />
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
border="2 solid neutral-100/60 dark:neutral-800/30"
|
||||
bg="neutral-50/70 dark:neutral-800/70"
|
||||
w-fit flex items-center justify-center rounded-xl p-2 backdrop-blur-md
|
||||
:title="isAuthenticated ? `Logged in as ${userName}` : 'Login'"
|
||||
to="/auth/login"
|
||||
>
|
||||
<div i-solar:user-bold-duotone />
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<!-- Authenticated: Avatar Dropdown -->
|
||||
<div v-else ref="dropdownRef" class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 border-2 border-neutral-100/60 rounded-full bg-neutral-50/70 p-1 pl-1 pr-3 backdrop-blur-md transition dark:border-neutral-800/30 dark:bg-neutral-800/70 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
:class="{ 'ring-2 ring-primary-500/20': showDropdown }"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="showDropdown ? 'true' : 'false'"
|
||||
@click="showDropdown = !showDropdown"
|
||||
>
|
||||
<img
|
||||
v-if="userAvatar"
|
||||
:src="userAvatar"
|
||||
class="h-8 w-8 rounded-full object-cover ring-2 ring-white dark:ring-neutral-900"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="h-8 w-8 flex items-center justify-center rounded-full bg-neutral-200 text-neutral-500 ring-2 ring-white dark:bg-neutral-700 dark:text-neutral-400 dark:ring-neutral-900"
|
||||
>
|
||||
<div class="i-solar:user-bold-duotone text-lg" />
|
||||
</div>
|
||||
|
||||
<span v-if="userName" class="max-w-[100px] truncate text-sm text-neutral-700 font-medium hidden sm:block dark:text-neutral-200">
|
||||
{{ userName }}
|
||||
</span>
|
||||
<div
|
||||
class="i-solar:alt-arrow-down-linear text-neutral-400 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': showDropdown }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="translate-y-1 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="translate-y-0 opacity-100"
|
||||
leave-to-class="translate-y-1 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showDropdown"
|
||||
class="absolute right-0 top-full z-50 mt-2 w-60 origin-top-right border border-neutral-200/60 rounded-xl bg-white/90 p-1 shadow-xl backdrop-blur-xl divide-y divide-neutral-100 dark:border-neutral-800/60 dark:bg-neutral-900/90 dark:divide-neutral-800"
|
||||
>
|
||||
<div class="px-3 py-2">
|
||||
<p class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Signed in as
|
||||
</p>
|
||||
<p class="truncate text-sm text-neutral-900 font-medium dark:text-white">
|
||||
{{ userName }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="py-1">
|
||||
<button
|
||||
class="group w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-neutral-700 transition hover:bg-neutral-100 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
||||
@click="handleListSessions"
|
||||
>
|
||||
<div class="i-solar:devices-bold-duotone text-lg text-neutral-400 transition group-hover:text-primary-500" />
|
||||
Active Sessions
|
||||
</button>
|
||||
|
||||
<RouterLink
|
||||
to="/settings"
|
||||
class="group w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-neutral-700 transition hover:bg-neutral-100 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
||||
@click="showDropdown = false"
|
||||
>
|
||||
<div class="i-solar:settings-minimalistic-bold-duotone text-lg text-neutral-400 transition group-hover:text-primary-500" />
|
||||
Settings
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="py-1">
|
||||
<button
|
||||
class="group w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<div class="i-solar:logout-3-bold-duotone text-lg transition group-hover:text-red-600 dark:group-hover:text-red-400" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import HeaderAvatar from './HeaderAvatar.vue'
|
||||
import MobileHeaderLink from './MobileHeaderLink.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header mb-1 w-full gap-2>
|
||||
<div w-full flex justify-center>
|
||||
<MobileHeaderLink />
|
||||
</div>
|
||||
<header mb-1 w-full flex items-center justify-between gap-2 px-2>
|
||||
<MobileHeaderLink />
|
||||
<HeaderAvatar />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main text="gray-700 dark:gray-200" h-full font-cute>
|
||||
<main h-full font-cute>
|
||||
<RouterView />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
Generated
+887
-167
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,7 @@ catalog:
|
||||
uncrypto: ^0.1.3
|
||||
unplugin-info: 1.2.4
|
||||
vite-plugin-mkcert: ^1.17.9
|
||||
valibot: ^1.2.0
|
||||
xsschema: ^0.4.0-beta.12
|
||||
zod: ^4.2.1
|
||||
|
||||
|
||||
Reference in New Issue
Block a user