mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +01:00
Merge 2000843b83 into 860e6390f1
This commit is contained in:
commit
d2e7856e14
16 changed files with 842 additions and 27 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)
|
||||
);
|
||||
|
||||
|
||||
|
|
@ -73,6 +73,7 @@
|
|||
"@react-spring/web": "^10.0.3",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@uiw/react-md-editor": "^4.0.11",
|
||||
"@umami/react-zen": "^0.211.0",
|
||||
"@umami/redis-client": "^0.29.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
|
|
@ -105,11 +106,13 @@
|
|||
"next": "^15.5.9",
|
||||
"node-fetch": "^3.2.8",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"openid-client": "^6.3.4",
|
||||
"papaparse": "^5.5.3",
|
||||
"pg": "^8.16.3",
|
||||
"prisma": "^6.18.0",
|
||||
"pure-rand": "^7.0.1",
|
||||
"react": "^19.2.3",
|
||||
"react-basics": "^0.126.0",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-error-boundary": "^4.0.4",
|
||||
"react-intl": "^7.1.14",
|
||||
|
|
|
|||
39
pnpm-lock.yaml
generated
39
pnpm-lock.yaml
generated
|
|
@ -140,6 +140,9 @@ importers:
|
|||
npm-run-all:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5
|
||||
openid-client:
|
||||
specifier: ^6.3.4
|
||||
version: 6.8.1
|
||||
papaparse:
|
||||
specifier: ^5.5.3
|
||||
version: 5.5.3
|
||||
|
|
@ -5341,11 +5344,6 @@ packages:
|
|||
nth-check@2.1.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -5372,9 +5370,13 @@ packages:
|
|||
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
onetime@7.0.0:
|
||||
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
|
||||
engines: {node: '>=18'}
|
||||
onetime@6.0.0:
|
||||
resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
ospath@1.2.2:
|
||||
resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==}
|
||||
|
|
@ -12439,8 +12441,6 @@ snapshots:
|
|||
- supports-color
|
||||
- ts-node
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
joycon@3.1.1: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
|
@ -12982,14 +12982,6 @@ snapshots:
|
|||
dependencies:
|
||||
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-inspect@1.13.4: {}
|
||||
|
|
@ -13017,7 +13009,16 @@ snapshots:
|
|||
|
||||
onetime@7.0.0:
|
||||
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: {}
|
||||
|
||||
|
|
|
|||
|
|
@ -316,3 +316,14 @@ model Pixel {
|
|||
@@index([createdAt])
|
||||
@@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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { SideMenu } from '@/components/common/SideMenu';
|
||||
import { useMessages, useNavigation } from '@/components/hooks';
|
||||
import { useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
|
||||
import { Settings2, UserCircle, Users } from '@/components/icons';
|
||||
|
||||
export function SettingsNav({ onItemClick }: { onItemClick?: () => void }) {
|
||||
const { user } = useLoginQuery();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { renderUrl, pathname } = useNavigation();
|
||||
|
||||
|
|
@ -33,7 +34,13 @@ export function SettingsNav({ onItemClick }: { onItemClick?: () => void }) {
|
|||
path: renderUrl('/settings/teams'),
|
||||
icon: <Users />,
|
||||
},
|
||||
],
|
||||
user?.isAdmin && {
|
||||
id: 'oidc',
|
||||
label: 'OIDC',
|
||||
path: renderUrl('/settings/oidc'),
|
||||
icon: <Settings2 />,
|
||||
},
|
||||
].filter(n => n),
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -41,6 +48,7 @@ export function SettingsNav({ onItemClick }: { onItemClick?: () => void }) {
|
|||
.flatMap(e => e.items)
|
||||
.find(({ path }) => path && pathname.includes(path.split('?')[0]))?.id;
|
||||
|
||||
|
||||
return (
|
||||
<SideMenu
|
||||
items={items}
|
||||
|
|
|
|||
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 redis from '@/lib/redis';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
|
|
@ -10,9 +11,6 @@ export async function POST(request: Request) {
|
|||
return error();
|
||||
}
|
||||
|
||||
if (redis.enabled) {
|
||||
const token = await saveAuth({ userId: auth.user.id }, 86400);
|
||||
|
||||
return json({ user: auth.user, token });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ import {
|
|||
Icon,
|
||||
PasswordField,
|
||||
TextField,
|
||||
Button,
|
||||
} from '@umami/react-zen';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMessages, useUpdateQuery } from '@/components/hooks';
|
||||
import { useApi, useMessages, useUpdateQuery } from '@/components/hooks';
|
||||
import { Logo } from '@/components/svg';
|
||||
import { setClientAuthToken } from '@/lib/client';
|
||||
import { setUser } from '@/store/app';
|
||||
|
|
@ -19,6 +20,19 @@ export function LoginForm() {
|
|||
const { formatMessage, labels, getErrorMessage } = useMessages();
|
||||
const router = useRouter();
|
||||
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) => {
|
||||
await mutateAsync(data, {
|
||||
|
|
@ -63,6 +77,14 @@ export function LoginForm() {
|
|||
>
|
||||
{formatMessage(labels.login)}
|
||||
</FormSubmitButton>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => startOIDC('/dashboard')}
|
||||
isDisabled={isOIDC}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
Se connecter avec OIDC
|
||||
</Button>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
</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