mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +01:00
Merge branch 'master' into umami-software/master
This commit is contained in:
commit
a9de07a2f1
15 changed files with 831 additions and 29 deletions
10
db/mysql/migrations/14_add_setting/migration.sql
Normal file
10
db/mysql/migrations/14_add_setting/migration.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- Create table setting
|
||||||
|
CREATE TABLE IF NOT EXISTS `setting` (
|
||||||
|
`setting_id` varchar(36) PRIMARY KEY,
|
||||||
|
`key` varchar(255) UNIQUE NOT NULL,
|
||||||
|
`value` varchar(4000),
|
||||||
|
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
281
db/mysql/schema.prisma
Normal file
281
db/mysql/schema.prisma
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "mysql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
relationMode = "prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @unique @map("user_id") @db.VarChar(36)
|
||||||
|
username String @unique @db.VarChar(255)
|
||||||
|
password String @db.VarChar(60)
|
||||||
|
role String @map("role") @db.VarChar(50)
|
||||||
|
logoUrl String? @map("logo_url") @db.VarChar(2183)
|
||||||
|
displayName String? @map("display_name") @db.VarChar(255)
|
||||||
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
|
||||||
|
deletedAt DateTime? @map("deleted_at") @db.Timestamp(0)
|
||||||
|
|
||||||
|
websiteUser Website[] @relation("user")
|
||||||
|
websiteCreateUser Website[] @relation("createUser")
|
||||||
|
teamUser TeamUser[]
|
||||||
|
report Report[]
|
||||||
|
|
||||||
|
@@map("user")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @unique @map("session_id") @db.VarChar(36)
|
||||||
|
websiteId String @map("website_id") @db.VarChar(36)
|
||||||
|
browser String? @db.VarChar(20)
|
||||||
|
os String? @db.VarChar(20)
|
||||||
|
device String? @db.VarChar(20)
|
||||||
|
screen String? @db.VarChar(11)
|
||||||
|
language String? @db.VarChar(35)
|
||||||
|
country String? @db.Char(2)
|
||||||
|
region String? @db.Char(20)
|
||||||
|
city String? @db.VarChar(50)
|
||||||
|
distinctId String? @map("distinct_id") @db.VarChar(50)
|
||||||
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
|
|
||||||
|
websiteEvent WebsiteEvent[]
|
||||||
|
sessionData SessionData[]
|
||||||
|
revenue Revenue[]
|
||||||
|
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([websiteId])
|
||||||
|
@@index([websiteId, createdAt])
|
||||||
|
@@index([websiteId, createdAt, browser])
|
||||||
|
@@index([websiteId, createdAt, os])
|
||||||
|
@@index([websiteId, createdAt, device])
|
||||||
|
@@index([websiteId, createdAt, screen])
|
||||||
|
@@index([websiteId, createdAt, language])
|
||||||
|
@@index([websiteId, createdAt, country])
|
||||||
|
@@index([websiteId, createdAt, region])
|
||||||
|
@@index([websiteId, createdAt, city])
|
||||||
|
@@map("session")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Website {
|
||||||
|
id String @id @unique @map("website_id") @db.VarChar(36)
|
||||||
|
name String @db.VarChar(100)
|
||||||
|
domain String? @db.VarChar(500)
|
||||||
|
shareId String? @unique @map("share_id") @db.VarChar(50)
|
||||||
|
resetAt DateTime? @map("reset_at") @db.Timestamp(0)
|
||||||
|
userId String? @map("user_id") @db.VarChar(36)
|
||||||
|
teamId String? @map("team_id") @db.VarChar(36)
|
||||||
|
createdBy String? @map("created_by") @db.VarChar(36)
|
||||||
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
|
||||||
|
deletedAt DateTime? @map("deleted_at") @db.Timestamp(0)
|
||||||
|
|
||||||
|
user User? @relation("user", fields: [userId], references: [id])
|
||||||
|
createUser User? @relation("createUser", fields: [createdBy], references: [id])
|
||||||
|
team Team? @relation(fields: [teamId], references: [id])
|
||||||
|
eventData EventData[]
|
||||||
|
report Report[]
|
||||||
|
revenue Revenue[]
|
||||||
|
sessionData SessionData[]
|
||||||
|
segment Segment[]
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([teamId])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([shareId])
|
||||||
|
@@index([createdBy])
|
||||||
|
@@map("website")
|
||||||
|
}
|
||||||
|
|
||||||
|
model WebsiteEvent {
|
||||||
|
id String @id() @map("event_id") @db.VarChar(36)
|
||||||
|
websiteId String @map("website_id") @db.VarChar(36)
|
||||||
|
sessionId String @map("session_id") @db.VarChar(36)
|
||||||
|
visitId String @map("visit_id") @db.VarChar(36)
|
||||||
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
|
urlPath String @map("url_path") @db.VarChar(500)
|
||||||
|
urlQuery String? @map("url_query") @db.VarChar(500)
|
||||||
|
utmSource String? @map("utm_source") @db.VarChar(255)
|
||||||
|
utmMedium String? @map("utm_medium") @db.VarChar(255)
|
||||||
|
utmCampaign String? @map("utm_campaign") @db.VarChar(255)
|
||||||
|
utmContent String? @map("utm_content") @db.VarChar(255)
|
||||||
|
utmTerm String? @map("utm_term") @db.VarChar(255)
|
||||||
|
referrerPath String? @map("referrer_path") @db.VarChar(500)
|
||||||
|
referrerQuery String? @map("referrer_query") @db.VarChar(500)
|
||||||
|
referrerDomain String? @map("referrer_domain") @db.VarChar(500)
|
||||||
|
pageTitle String? @map("page_title") @db.VarChar(500)
|
||||||
|
gclid String? @map("gclid") @db.VarChar(255)
|
||||||
|
fbclid String? @map("fbclid") @db.VarChar(255)
|
||||||
|
msclkid String? @map("msclkid") @db.VarChar(255)
|
||||||
|
ttclid String? @map("ttclid") @db.VarChar(255)
|
||||||
|
lifatid String? @map("li_fat_id") @db.VarChar(255)
|
||||||
|
twclid String? @map("twclid") @db.VarChar(255)
|
||||||
|
eventType Int @default(1) @map("event_type") @db.UnsignedInt
|
||||||
|
eventName String? @map("event_name") @db.VarChar(50)
|
||||||
|
tag String? @db.VarChar(50)
|
||||||
|
hostname String? @db.VarChar(100)
|
||||||
|
|
||||||
|
eventData EventData[]
|
||||||
|
session Session @relation(fields: [sessionId], references: [id])
|
||||||
|
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([sessionId])
|
||||||
|
@@index([visitId])
|
||||||
|
@@index([websiteId])
|
||||||
|
@@index([websiteId, createdAt])
|
||||||
|
@@index([websiteId, createdAt, urlPath])
|
||||||
|
@@index([websiteId, createdAt, urlQuery])
|
||||||
|
@@index([websiteId, createdAt, referrerDomain])
|
||||||
|
@@index([websiteId, createdAt, pageTitle])
|
||||||
|
@@index([websiteId, createdAt, eventName])
|
||||||
|
@@index([websiteId, createdAt, tag])
|
||||||
|
@@index([websiteId, sessionId, createdAt])
|
||||||
|
@@index([websiteId, visitId, createdAt])
|
||||||
|
@@index([websiteId, createdAt, hostname])
|
||||||
|
@@map("website_event")
|
||||||
|
}
|
||||||
|
|
||||||
|
model EventData {
|
||||||
|
id String @id() @map("event_data_id") @db.VarChar(36)
|
||||||
|
websiteId String @map("website_id") @db.VarChar(36)
|
||||||
|
websiteEventId String @map("website_event_id") @db.VarChar(36)
|
||||||
|
dataKey String @map("data_key") @db.VarChar(500)
|
||||||
|
stringValue String? @map("string_value") @db.VarChar(500)
|
||||||
|
numberValue Decimal? @map("number_value") @db.Decimal(19, 4)
|
||||||
|
dateValue DateTime? @map("date_value") @db.Timestamp(0)
|
||||||
|
dataType Int @map("data_type") @db.UnsignedInt
|
||||||
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
|
|
||||||
|
website Website @relation(fields: [websiteId], references: [id])
|
||||||
|
websiteEvent WebsiteEvent @relation(fields: [websiteEventId], references: [id])
|
||||||
|
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([websiteId])
|
||||||
|
@@index([websiteEventId])
|
||||||
|
@@index([websiteId, createdAt])
|
||||||
|
@@index([websiteId, createdAt, dataKey])
|
||||||
|
@@map("event_data")
|
||||||
|
}
|
||||||
|
|
||||||
|
model SessionData {
|
||||||
|
id String @id() @map("session_data_id") @db.VarChar(36)
|
||||||
|
websiteId String @map("website_id") @db.VarChar(36)
|
||||||
|
sessionId String @map("session_id") @db.VarChar(36)
|
||||||
|
dataKey String @map("data_key") @db.VarChar(500)
|
||||||
|
stringValue String? @map("string_value") @db.VarChar(500)
|
||||||
|
numberValue Decimal? @map("number_value") @db.Decimal(19, 4)
|
||||||
|
dateValue DateTime? @map("date_value") @db.Timestamp(0)
|
||||||
|
dataType Int @map("data_type") @db.UnsignedInt
|
||||||
|
distinctId String? @map("distinct_id") @db.VarChar(50)
|
||||||
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
|
|
||||||
|
website Website @relation(fields: [websiteId], references: [id])
|
||||||
|
session Session @relation(fields: [sessionId], references: [id])
|
||||||
|
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([websiteId])
|
||||||
|
@@index([sessionId])
|
||||||
|
@@index([sessionId, createdAt])
|
||||||
|
@@index([websiteId, createdAt, dataKey])
|
||||||
|
@@map("session_data")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Team {
|
||||||
|
id String @id() @unique() @map("team_id") @db.VarChar(36)
|
||||||
|
name String @db.VarChar(50)
|
||||||
|
accessCode String? @unique @map("access_code") @db.VarChar(50)
|
||||||
|
logoUrl String? @map("logo_url") @db.VarChar(2183)
|
||||||
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
|
||||||
|
deletedAt DateTime? @map("deleted_at") @db.Timestamp(0)
|
||||||
|
|
||||||
|
website Website[]
|
||||||
|
teamUser TeamUser[]
|
||||||
|
|
||||||
|
@@index([accessCode])
|
||||||
|
@@map("team")
|
||||||
|
}
|
||||||
|
|
||||||
|
model TeamUser {
|
||||||
|
id String @id() @unique() @map("team_user_id") @db.VarChar(36)
|
||||||
|
teamId String @map("team_id") @db.VarChar(36)
|
||||||
|
userId String @map("user_id") @db.VarChar(36)
|
||||||
|
role String @map("role") @db.VarChar(50)
|
||||||
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
|
||||||
|
|
||||||
|
team Team @relation(fields: [teamId], references: [id])
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
@@index([teamId])
|
||||||
|
@@index([userId])
|
||||||
|
@@map("team_user")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Report {
|
||||||
|
id String @id() @unique() @map("report_id") @db.VarChar(36)
|
||||||
|
userId String @map("user_id") @db.VarChar(36)
|
||||||
|
websiteId String @map("website_id") @db.VarChar(36)
|
||||||
|
type String @db.VarChar(200)
|
||||||
|
name String @db.VarChar(200)
|
||||||
|
description String @db.VarChar(500)
|
||||||
|
parameters Json
|
||||||
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
website Website @relation(fields: [websiteId], references: [id])
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([websiteId])
|
||||||
|
@@index([type])
|
||||||
|
@@index([name])
|
||||||
|
@@map("report")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Segment {
|
||||||
|
id String @id() @unique() @map("segment_id") @db.VarChar(36)
|
||||||
|
websiteId String @map("website_id") @db.VarChar(36)
|
||||||
|
type String @db.VarChar(200)
|
||||||
|
name String @db.VarChar(200)
|
||||||
|
parameters Json
|
||||||
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
|
||||||
|
|
||||||
|
website Website @relation(fields: [websiteId], references: [id])
|
||||||
|
|
||||||
|
@@index([websiteId])
|
||||||
|
@@map("segment")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Revenue {
|
||||||
|
id String @id() @unique() @map("revenue_id") @db.VarChar(36)
|
||||||
|
websiteId String @map("website_id") @db.VarChar(36)
|
||||||
|
sessionId String @map("session_id") @db.VarChar(36)
|
||||||
|
eventId String @map("event_id") @db.VarChar(36)
|
||||||
|
eventName String @map("event_name") @db.VarChar(50)
|
||||||
|
currency String @db.VarChar(100)
|
||||||
|
revenue Decimal? @db.Decimal(19, 4)
|
||||||
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
|
|
||||||
|
website Website @relation(fields: [websiteId], references: [id])
|
||||||
|
session Session @relation(fields: [sessionId], references: [id])
|
||||||
|
|
||||||
|
@@index([websiteId])
|
||||||
|
@@index([sessionId])
|
||||||
|
@@index([websiteId, createdAt])
|
||||||
|
@@index([websiteId, sessionId, createdAt])
|
||||||
|
@@map("revenue")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Setting {
|
||||||
|
id String @id @unique @map("setting_id") @db.VarChar(36)
|
||||||
|
key String @unique @db.VarChar(255)
|
||||||
|
value String? @db.VarChar(4000)
|
||||||
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamp(0)
|
||||||
|
|
||||||
|
@@map("setting")
|
||||||
|
}
|
||||||
10
db/postgresql/migrations/14_add_setting/migration.sql
Normal file
10
db/postgresql/migrations/14_add_setting/migration.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- Create table setting
|
||||||
|
CREATE TABLE IF NOT EXISTS "setting" (
|
||||||
|
"setting_id" uuid PRIMARY KEY,
|
||||||
|
"key" varchar(255) UNIQUE NOT NULL,
|
||||||
|
"value" varchar(4000),
|
||||||
|
"created_at" timestamptz(6) DEFAULT now(),
|
||||||
|
"updated_at" timestamptz(6)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -111,6 +111,8 @@
|
||||||
"pure-rand": "^7.0.1",
|
"pure-rand": "^7.0.1",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
|
"openid-client": "^6.3.4",
|
||||||
|
"react-basics": "^0.126.0",
|
||||||
"react-error-boundary": "^4.0.4",
|
"react-error-boundary": "^4.0.4",
|
||||||
"react-intl": "^7.1.14",
|
"react-intl": "^7.1.14",
|
||||||
"react-simple-maps": "^2.3.0",
|
"react-simple-maps": "^2.3.0",
|
||||||
|
|
|
||||||
43
pnpm-lock.yaml
generated
43
pnpm-lock.yaml
generated
|
|
@ -140,6 +140,9 @@ importers:
|
||||||
npm-run-all:
|
npm-run-all:
|
||||||
specifier: ^4.1.5
|
specifier: ^4.1.5
|
||||||
version: 4.1.5
|
version: 4.1.5
|
||||||
|
openid-client:
|
||||||
|
specifier: ^6.3.4
|
||||||
|
version: 6.8.1
|
||||||
papaparse:
|
papaparse:
|
||||||
specifier: ^5.5.3
|
specifier: ^5.5.3
|
||||||
version: 5.5.3
|
version: 5.5.3
|
||||||
|
|
@ -4805,10 +4808,6 @@ packages:
|
||||||
node-notifier:
|
node-notifier:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
jiti@2.6.1:
|
|
||||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
joycon@3.1.1:
|
joycon@3.1.1:
|
||||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -5341,11 +5340,6 @@ packages:
|
||||||
nth-check@2.1.1:
|
nth-check@2.1.1:
|
||||||
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
||||||
|
|
||||||
nypm@0.6.2:
|
|
||||||
resolution: {integrity: sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==}
|
|
||||||
engines: {node: ^14.16.0 || >=16.10.0}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
object-assign@4.1.1:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -5372,9 +5366,13 @@ packages:
|
||||||
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
|
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
onetime@7.0.0:
|
onetime@6.0.0:
|
||||||
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
|
resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
optionator@0.9.4:
|
||||||
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
ospath@1.2.2:
|
ospath@1.2.2:
|
||||||
resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==}
|
resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==}
|
||||||
|
|
@ -12439,8 +12437,6 @@ snapshots:
|
||||||
- supports-color
|
- supports-color
|
||||||
- ts-node
|
- ts-node
|
||||||
|
|
||||||
jiti@2.6.1: {}
|
|
||||||
|
|
||||||
joycon@3.1.1: {}
|
joycon@3.1.1: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
@ -12982,14 +12978,6 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
boolbase: 1.0.0
|
boolbase: 1.0.0
|
||||||
|
|
||||||
nypm@0.6.2:
|
|
||||||
dependencies:
|
|
||||||
citty: 0.1.6
|
|
||||||
consola: 3.4.2
|
|
||||||
pathe: 2.0.3
|
|
||||||
pkg-types: 2.3.0
|
|
||||||
tinyexec: 1.0.2
|
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
|
|
@ -13017,7 +13005,16 @@ snapshots:
|
||||||
|
|
||||||
onetime@7.0.0:
|
onetime@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
mimic-function: 5.0.1
|
mimic-fn: 4.0.0
|
||||||
|
|
||||||
|
optionator@0.9.4:
|
||||||
|
dependencies:
|
||||||
|
deep-is: 0.1.4
|
||||||
|
fast-levenshtein: 2.0.6
|
||||||
|
levn: 0.4.1
|
||||||
|
prelude-ls: 1.2.1
|
||||||
|
type-check: 0.4.0
|
||||||
|
word-wrap: 1.2.5
|
||||||
|
|
||||||
ospath@1.2.2: {}
|
ospath@1.2.2: {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -316,3 +316,14 @@ model Pixel {
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("pixel")
|
@@map("pixel")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Setting {
|
||||||
|
id String @id @unique @map("setting_id") @db.Uuid
|
||||||
|
key String @unique @db.VarChar(255)
|
||||||
|
value String? @db.VarChar(4000)
|
||||||
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
@@map("setting")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
85
src/app/(main)/settings/OIDCSettingsPage.tsx
Normal file
85
src/app/(main)/settings/OIDCSettingsPage.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
'use client';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormRow,
|
||||||
|
FormInput,
|
||||||
|
FormButtons,
|
||||||
|
TextField,
|
||||||
|
PasswordField,
|
||||||
|
SubmitButton,
|
||||||
|
} from 'react-basics';
|
||||||
|
import PageHeader from '@/components/layout/PageHeader';
|
||||||
|
import { useApi, useMessages } from '@/components/hooks';
|
||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
|
||||||
|
export default function OIDCSettingsPage() {
|
||||||
|
const { get, post, useMutation } = useApi();
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const [values, setValues] = useState<any>({});
|
||||||
|
const ref = useRef(null);
|
||||||
|
const { mutate, error, isPending } = useMutation({
|
||||||
|
mutationFn: (data: any) => post('/admin/oidc', data),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const cfg = await get('/admin/oidc');
|
||||||
|
setValues(cfg || {});
|
||||||
|
} catch (e) {
|
||||||
|
// ignore load errors; form will remain empty
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [get]);
|
||||||
|
|
||||||
|
const handleSubmit = (data: any) => {
|
||||||
|
mutate(data, {
|
||||||
|
onSuccess: async () => {
|
||||||
|
ref.current?.reset?.(data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="OIDC" />
|
||||||
|
<Form ref={ref} onSubmit={handleSubmit} values={values} error={error}>
|
||||||
|
<FormRow label="Issuer URL">
|
||||||
|
<FormInput name="issuerUrl" rules={{ required: formatMessage(labels.required) }}>
|
||||||
|
<TextField />
|
||||||
|
</FormInput>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Client ID">
|
||||||
|
<FormInput name="clientId" rules={{ required: formatMessage(labels.required) }}>
|
||||||
|
<TextField />
|
||||||
|
</FormInput>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Client Secret">
|
||||||
|
<FormInput name="clientSecret">
|
||||||
|
<PasswordField />
|
||||||
|
</FormInput>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Redirect URI">
|
||||||
|
<FormInput name="redirectUri" rules={{ required: formatMessage(labels.required) }}>
|
||||||
|
<TextField />
|
||||||
|
</FormInput>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Scopes">
|
||||||
|
<FormInput name="scopes">
|
||||||
|
<TextField placeholder="openid profile email" />
|
||||||
|
</FormInput>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Username claim">
|
||||||
|
<FormInput name="usernameClaim">
|
||||||
|
<TextField placeholder="preferred_username" />
|
||||||
|
</FormInput>
|
||||||
|
</FormRow>
|
||||||
|
<FormButtons>
|
||||||
|
<SubmitButton variant="primary" disabled={isPending}>
|
||||||
|
{formatMessage(labels.save)}
|
||||||
|
</SubmitButton>
|
||||||
|
</FormButtons>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/app/(main)/settings/oidc/page.tsx
Normal file
10
src/app/(main)/settings/oidc/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import OIDCSettingsPage from '@/app/(main)/settings/OIDCSettingsPage';
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
return <OIDCSettingsPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'OIDC',
|
||||||
|
};
|
||||||
46
src/app/api/admin/oidc/route.ts
Normal file
46
src/app/api/admin/oidc/route.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
import { parseRequest } from '@/lib/request';
|
||||||
|
import { json, unauthorized } from '@/lib/response';
|
||||||
|
import { getEffectiveOIDCConfig } from '@/lib/oidc';
|
||||||
|
import { setSetting } from '@/queries/prisma/setting';
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
issuerUrl: z.string().url(),
|
||||||
|
clientId: z.string().min(1),
|
||||||
|
clientSecret: z.string().optional(),
|
||||||
|
redirectUri: z.string().url(),
|
||||||
|
scopes: z.string().default('openid profile email').optional(),
|
||||||
|
usernameClaim: z.string().default('preferred_username').optional(),
|
||||||
|
autoCreateUsers: z.boolean().default(true).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { auth, error } = await parseRequest(request);
|
||||||
|
if (error) return error();
|
||||||
|
if (!auth?.user?.isAdmin) return unauthorized();
|
||||||
|
|
||||||
|
const cfg = await getEffectiveOIDCConfig();
|
||||||
|
return json(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
if (error) return error();
|
||||||
|
if (!auth?.user?.isAdmin) return unauthorized();
|
||||||
|
|
||||||
|
const { issuerUrl, clientId, clientSecret, redirectUri, scopes, usernameClaim, autoCreateUsers } =
|
||||||
|
body;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
setSetting('oidc:issuerUrl', issuerUrl),
|
||||||
|
setSetting('oidc:clientId', clientId),
|
||||||
|
setSetting('oidc:clientSecret', clientSecret || null),
|
||||||
|
setSetting('oidc:redirectUri', redirectUri),
|
||||||
|
setSetting('oidc:scopes', scopes || 'openid profile email'),
|
||||||
|
setSetting('oidc:usernameClaim', usernameClaim || 'preferred_username'),
|
||||||
|
setSetting('oidc:autoCreateUsers', String(Boolean(autoCreateUsers))),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return json({ success: true });
|
||||||
|
}
|
||||||
40
src/app/api/auth/oidc/authorize/route.ts
Normal file
40
src/app/api/auth/oidc/authorize/route.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import {
|
||||||
|
getEffectiveOIDCConfig,
|
||||||
|
generateState,
|
||||||
|
generateCodeVerifier,
|
||||||
|
generateCodeChallenge,
|
||||||
|
getAuthorizationUrl,
|
||||||
|
} from '@/lib/oidc';
|
||||||
|
import { json, badRequest } from '@/lib/response';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const cfg = await getEffectiveOIDCConfig();
|
||||||
|
|
||||||
|
if (!cfg.enabled) {
|
||||||
|
return badRequest('OIDC is not enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const returnUrl = url.searchParams.get('returnUrl') || '/dashboard';
|
||||||
|
|
||||||
|
const state = await generateState();
|
||||||
|
const codeVerifier = await generateCodeVerifier();
|
||||||
|
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||||
|
|
||||||
|
const authUrl = await getAuthorizationUrl(cfg, state, codeChallenge);
|
||||||
|
|
||||||
|
const stateData = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
state,
|
||||||
|
codeVerifier,
|
||||||
|
returnUrl,
|
||||||
|
}),
|
||||||
|
).toString('base64url');
|
||||||
|
|
||||||
|
const finalAuthUrl = authUrl.replace(`state=${state}`, `state=${stateData}`);
|
||||||
|
|
||||||
|
return json({ url: finalAuthUrl });
|
||||||
|
}
|
||||||
97
src/app/api/auth/oidc/callback/route.ts
Normal file
97
src/app/api/auth/oidc/callback/route.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import {
|
||||||
|
getEffectiveOIDCConfig,
|
||||||
|
getOIDCUsernameFromIdToken,
|
||||||
|
exchangeCodeForToken,
|
||||||
|
} from '@/lib/oidc';
|
||||||
|
import { badRequest, unauthorized } from '@/lib/response';
|
||||||
|
import { saveAuth } from '@/lib/auth';
|
||||||
|
import { ROLES } from '@/lib/constants';
|
||||||
|
import { getUserByUsername, createUser } from '@/queries';
|
||||||
|
import { uuid, secret } from '@/lib/crypto';
|
||||||
|
import { createSecureToken } from '@/lib/jwt';
|
||||||
|
import redis from '@/lib/redis';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const cfg = await getEffectiveOIDCConfig();
|
||||||
|
|
||||||
|
if (!cfg.enabled) {
|
||||||
|
return badRequest('OIDC is not enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
const state = url.searchParams.get('state');
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
return badRequest('Missing code or state parameter');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Décoder les données du state
|
||||||
|
let stateData;
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(state, 'base64url').toString('utf8');
|
||||||
|
stateData = JSON.parse(decoded);
|
||||||
|
} catch (e) {
|
||||||
|
return badRequest('Invalid state parameter format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { codeVerifier, returnUrl } = stateData;
|
||||||
|
const returnCookie = returnUrl || '/dashboard';
|
||||||
|
|
||||||
|
const tokens = await exchangeCodeForToken(cfg, code, codeVerifier);
|
||||||
|
const idToken = tokens.id_token;
|
||||||
|
|
||||||
|
if (!idToken) {
|
||||||
|
return unauthorized('Missing id_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = getOIDCUsernameFromIdToken(idToken, cfg.usernameClaim);
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
return unauthorized('Unable to resolve username from id_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = await getUserByUsername(username);
|
||||||
|
|
||||||
|
if (!user && cfg.autoCreateUsers) {
|
||||||
|
user = await createUser({ id: uuid(), username, password: uuid(), role: ROLES.user });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return unauthorized('User not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
let token: string;
|
||||||
|
if (redis.enabled) {
|
||||||
|
token = await saveAuth({ userId: user.id, role: user.role });
|
||||||
|
} else {
|
||||||
|
token = createSecureToken({ userId: user.id, role: user.role }, secret());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruit l'origine depuis les en-têtes proxy si présents
|
||||||
|
const headers = request.headers;
|
||||||
|
const forwardedProto = headers.get('x-forwarded-proto');
|
||||||
|
const forwardedHost = headers.get('x-forwarded-host') || headers.get('host');
|
||||||
|
const forwardedPort = headers.get('x-forwarded-port');
|
||||||
|
|
||||||
|
let baseOrigin = '';
|
||||||
|
if (forwardedProto && forwardedHost) {
|
||||||
|
// Ajoute le port si fourni et non déjà inclus dans le host
|
||||||
|
const hasPortInHost = forwardedHost.includes(':');
|
||||||
|
const hostWithPort = !hasPortInHost && forwardedPort
|
||||||
|
? `${forwardedHost}:${forwardedPort}`
|
||||||
|
: forwardedHost;
|
||||||
|
baseOrigin = `${forwardedProto}://${hostWithPort}`;
|
||||||
|
} else {
|
||||||
|
baseOrigin = new URL(request.url).origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = baseOrigin;
|
||||||
|
const ssoUrl = `${baseUrl}/sso?url=${encodeURIComponent(returnCookie)}&token=${encodeURIComponent(
|
||||||
|
token,
|
||||||
|
)}`;
|
||||||
|
return Response.redirect(ssoUrl, 302);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
|
||||||
import { saveAuth } from '@/lib/auth';
|
import { saveAuth } from '@/lib/auth';
|
||||||
import redis from '@/lib/redis';
|
import redis from '@/lib/redis';
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
|
|
@ -10,9 +11,6 @@ export async function POST(request: Request) {
|
||||||
return error();
|
return error();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (redis.enabled) {
|
|
||||||
const token = await saveAuth({ userId: auth.user.id }, 86400);
|
const token = await saveAuth({ userId: auth.user.id }, 86400);
|
||||||
|
|
||||||
return json({ user: auth.user, token });
|
return json({ user: auth.user, token });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ import {
|
||||||
Icon,
|
Icon,
|
||||||
PasswordField,
|
PasswordField,
|
||||||
TextField,
|
TextField,
|
||||||
|
Button,
|
||||||
} from '@umami/react-zen';
|
} from '@umami/react-zen';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useMessages, useUpdateQuery } from '@/components/hooks';
|
import { useApi, useMessages, useUpdateQuery } from '@/components/hooks';
|
||||||
import { Logo } from '@/components/svg';
|
import { Logo } from '@/components/svg';
|
||||||
import { setClientAuthToken } from '@/lib/client';
|
import { setClientAuthToken } from '@/lib/client';
|
||||||
import { setUser } from '@/store/app';
|
import { setUser } from '@/store/app';
|
||||||
|
|
@ -19,6 +20,19 @@ export function LoginForm() {
|
||||||
const { formatMessage, labels, getErrorMessage } = useMessages();
|
const { formatMessage, labels, getErrorMessage } = useMessages();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { mutateAsync, error } = useUpdateQuery('/auth/login');
|
const { mutateAsync, error } = useUpdateQuery('/auth/login');
|
||||||
|
const { useMutation } = useApi();
|
||||||
|
const { mutate: startOIDC, isPending: isOIDC } = useMutation({
|
||||||
|
mutationFn: async (returnUrl?: string) => {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/auth/oidc/authorize?returnUrl=${encodeURIComponent(returnUrl || '/dashboard')}`,
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data?.url) {
|
||||||
|
window.location.href = data.url;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = async (data: any) => {
|
const handleSubmit = async (data: any) => {
|
||||||
await mutateAsync(data, {
|
await mutateAsync(data, {
|
||||||
|
|
@ -63,6 +77,14 @@ export function LoginForm() {
|
||||||
>
|
>
|
||||||
{formatMessage(labels.login)}
|
{formatMessage(labels.login)}
|
||||||
</FormSubmitButton>
|
</FormSubmitButton>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => startOIDC('/dashboard')}
|
||||||
|
isDisabled={isOIDC}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
Se connecter avec OIDC
|
||||||
|
</Button>
|
||||||
</FormButtons>
|
</FormButtons>
|
||||||
</Form>
|
</Form>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
|
||||||
177
src/lib/oidc.ts
Normal file
177
src/lib/oidc.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
import { createHash, randomBytes } from 'crypto';
|
||||||
|
import { getSetting } from '@/queries/prisma/setting';
|
||||||
|
|
||||||
|
export type OIDCConfig = {
|
||||||
|
issuerUrl: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
redirectUri: string;
|
||||||
|
scopes: string;
|
||||||
|
usernameClaim: string;
|
||||||
|
autoCreateUsers: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WellKnown = {
|
||||||
|
issuer: string;
|
||||||
|
authorization_endpoint: string;
|
||||||
|
token_endpoint: string;
|
||||||
|
userinfo_endpoint?: string;
|
||||||
|
jwks_uri?: string;
|
||||||
|
end_session_endpoint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const wellKnownCache: { [issuer: string]: WellKnown } = {};
|
||||||
|
|
||||||
|
export function getOIDCConfig(): OIDCConfig {
|
||||||
|
const issuerUrl = process.env.OIDC_ISSUER_URL || '';
|
||||||
|
const clientId = process.env.OIDC_CLIENT_ID || '';
|
||||||
|
const clientSecret = process.env.OIDC_CLIENT_SECRET || '';
|
||||||
|
const redirectUri = process.env.OIDC_REDIRECT_URI || '';
|
||||||
|
const scopes = process.env.OIDC_SCOPES || 'openid profile email';
|
||||||
|
const usernameClaim = process.env.OIDC_USERNAME_CLAIM || 'preferred_username';
|
||||||
|
const autoCreateUsers = (process.env.OIDC_AUTO_CREATE_USERS || 'true').toLowerCase() === 'true';
|
||||||
|
|
||||||
|
const enabled = Boolean(issuerUrl && clientId && redirectUri);
|
||||||
|
|
||||||
|
return {
|
||||||
|
issuerUrl,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
redirectUri,
|
||||||
|
scopes,
|
||||||
|
usernameClaim,
|
||||||
|
autoCreateUsers,
|
||||||
|
enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEffectiveOIDCConfig(): Promise<OIDCConfig> {
|
||||||
|
const envCfg = getOIDCConfig();
|
||||||
|
const [issuerUrl, clientId, clientSecret, redirectUri, scopes, usernameClaim, autoCreateUsers] =
|
||||||
|
await Promise.all([
|
||||||
|
getSetting('oidc:issuerUrl'),
|
||||||
|
getSetting('oidc:clientId'),
|
||||||
|
getSetting('oidc:clientSecret'),
|
||||||
|
getSetting('oidc:redirectUri'),
|
||||||
|
getSetting('oidc:scopes'),
|
||||||
|
getSetting('oidc:usernameClaim'),
|
||||||
|
getSetting('oidc:autoCreateUsers'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cfg: OIDCConfig = {
|
||||||
|
issuerUrl: (issuerUrl || envCfg.issuerUrl)?.replace(/\/$/, ''),
|
||||||
|
clientId: clientId || envCfg.clientId,
|
||||||
|
clientSecret: clientSecret || envCfg.clientSecret,
|
||||||
|
redirectUri: redirectUri || envCfg.redirectUri,
|
||||||
|
scopes: scopes || envCfg.scopes,
|
||||||
|
usernameClaim: usernameClaim || envCfg.usernameClaim,
|
||||||
|
autoCreateUsers: (autoCreateUsers || String(envCfg.autoCreateUsers)).toLowerCase() === 'true',
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
cfg.enabled = Boolean(cfg.issuerUrl && cfg.clientId && cfg.redirectUri);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWellKnown(issuerOrWellKnown: string): Promise<WellKnown> {
|
||||||
|
const base = issuerOrWellKnown.replace(/\/$/, '');
|
||||||
|
const wellKnownUrl = base.includes('/.well-known/openid-configuration')
|
||||||
|
? base
|
||||||
|
: `${base}/.well-known/openid-configuration`;
|
||||||
|
|
||||||
|
if (wellKnownCache[wellKnownUrl]) return wellKnownCache[wellKnownUrl];
|
||||||
|
|
||||||
|
const res = await fetch(wellKnownUrl, { method: 'GET' });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to load OIDC discovery document: ${res.status}`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as WellKnown;
|
||||||
|
wellKnownCache[wellKnownUrl] = data;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64url(input: Buffer) {
|
||||||
|
return input.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateState(): Promise<string> {
|
||||||
|
return base64url(randomBytes(16));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateCodeVerifier(): Promise<string> {
|
||||||
|
return base64url(randomBytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||||
|
const hash = createHash('sha256').update(verifier).digest();
|
||||||
|
return base64url(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuthorizationUrl(cfg: OIDCConfig, state: string, codeChallenge: string) {
|
||||||
|
const wk = await fetchWellKnown(cfg.issuerUrl);
|
||||||
|
const url = new URL(wk.authorization_endpoint);
|
||||||
|
url.searchParams.set('client_id', cfg.clientId);
|
||||||
|
url.searchParams.set('redirect_uri', cfg.redirectUri);
|
||||||
|
url.searchParams.set('response_type', 'code');
|
||||||
|
url.searchParams.set('scope', cfg.scopes);
|
||||||
|
url.searchParams.set('state', state);
|
||||||
|
url.searchParams.set('code_challenge', codeChallenge);
|
||||||
|
url.searchParams.set('code_challenge_method', 'S256');
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TokenSetLite = {
|
||||||
|
access_token?: string;
|
||||||
|
id_token?: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
token_type?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function exchangeCodeForToken(
|
||||||
|
cfg: OIDCConfig,
|
||||||
|
code: string,
|
||||||
|
codeVerifier: string,
|
||||||
|
): Promise<TokenSetLite> {
|
||||||
|
const wk = await fetchWellKnown(cfg.issuerUrl);
|
||||||
|
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set('grant_type', 'authorization_code');
|
||||||
|
body.set('code', code);
|
||||||
|
body.set('redirect_uri', cfg.redirectUri);
|
||||||
|
body.set('client_id', cfg.clientId);
|
||||||
|
body.set('code_verifier', codeVerifier);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { 'content-type': 'application/x-www-form-urlencoded' };
|
||||||
|
|
||||||
|
if (cfg.clientSecret) {
|
||||||
|
const basic = Buffer.from(`${cfg.clientId}:${cfg.clientSecret}`).toString('base64');
|
||||||
|
headers['authorization'] = `Basic ${basic}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(wk.token_endpoint, { method: 'POST', headers, body });
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Token exchange failed: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as TokenSetLite;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeJwtClaims(idToken: string): any {
|
||||||
|
const parts = idToken.split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
const payload = parts[1];
|
||||||
|
const buf = Buffer.from(payload.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
||||||
|
try {
|
||||||
|
return JSON.parse(buf.toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOIDCUsernameFromIdToken(idToken: string, usernameClaim: string): string | null {
|
||||||
|
const claims = decodeJwtClaims(idToken) || {};
|
||||||
|
const val = claims?.[usernameClaim] || claims?.email || claims?.sub;
|
||||||
|
return typeof val === 'string' ? val : null;
|
||||||
|
}
|
||||||
16
src/queries/prisma/setting.ts
Normal file
16
src/queries/prisma/setting.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
import { uuid } from '@/lib/crypto';
|
||||||
|
|
||||||
|
export async function getSetting(key: string): Promise<string | null> {
|
||||||
|
const row = await prisma.client.setting.findUnique({ where: { key }, select: { value: true } });
|
||||||
|
return row?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSetting(key: string, value: string | null): Promise<void> {
|
||||||
|
const existing = await prisma.client.setting.findUnique({ where: { key } });
|
||||||
|
if (existing) {
|
||||||
|
await prisma.client.setting.update({ where: { key }, data: { value } });
|
||||||
|
} else {
|
||||||
|
await prisma.client.setting.create({ data: { id: uuid(), key, value } });
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue