mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-23 06:11:58 +00:00
feat: implement OIDC authentication and enhance user management features
- Introduced OIDC authentication support with new OIDCService, controller, and routes for handling OIDC login and callback processes. - Updated user management functionalities to integrate OIDC, allowing users to authenticate via external providers. - Enhanced localization files to include new strings related to OIDC authentication across multiple languages. - Refactored existing components and hooks to support the new authentication flow, improving user experience during login and registration processes. - Added new UI components for handling OIDC login and callback, ensuring a seamless integration with the existing application structure.
This commit is contained in:
@@ -45,7 +45,10 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"fastify": "^5.2.1",
|
||||
"fastify-type-provider-zod": "^4.0.2",
|
||||
"jose": "^5.10.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"nodemailer": "^6.10.0",
|
||||
"openid-client": "^6.5.0",
|
||||
"sharp": "^0.33.5",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
|
78
apps/server/pnpm-lock.yaml
generated
78
apps/server/pnpm-lock.yaml
generated
@@ -56,9 +56,18 @@ importers:
|
||||
fastify-type-provider-zod:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2(fastify@5.2.1)(zod@3.24.2)
|
||||
jose:
|
||||
specifier: ^5.10.0
|
||||
version: 5.10.0
|
||||
node-fetch:
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.2
|
||||
nodemailer:
|
||||
specifier: ^6.10.0
|
||||
version: 6.10.0
|
||||
openid-client:
|
||||
specifier: ^6.5.0
|
||||
version: 6.5.0
|
||||
sharp:
|
||||
specifier: ^0.33.5
|
||||
version: 0.33.5
|
||||
@@ -1243,6 +1252,10 @@ packages:
|
||||
crypto-js@4.2.0:
|
||||
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
||||
|
||||
data-uri-to-buffer@4.0.1:
|
||||
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
data-view-buffer@1.0.2:
|
||||
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1511,6 +1524,10 @@ packages:
|
||||
fastseries@1.7.2:
|
||||
resolution: {integrity: sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==}
|
||||
|
||||
fetch-blob@3.2.0:
|
||||
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
|
||||
engines: {node: ^12.20 || >= 14.13}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@@ -1542,6 +1559,10 @@ packages:
|
||||
resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
formdata-polyfill@4.0.10:
|
||||
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -1781,6 +1802,12 @@ packages:
|
||||
javascript-natural-sort@0.7.1:
|
||||
resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==}
|
||||
|
||||
jose@5.10.0:
|
||||
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
|
||||
|
||||
jose@6.0.11:
|
||||
resolution: {integrity: sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -1890,10 +1917,22 @@ packages:
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
deprecated: Use your platform's native DOMException instead
|
||||
|
||||
node-fetch@3.3.2:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
nodemailer@6.10.0:
|
||||
resolution: {integrity: sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
oauth4webapi@3.5.1:
|
||||
resolution: {integrity: sha512-txg/jZQwcbaF7PMJgY7aoxc9QuCxHVFMiEkDIJ60DwDz3PbtXPQnrzo+3X4IRYGChIwWLabRBRpf1k9hO9+xrQ==}
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1928,6 +1967,9 @@ packages:
|
||||
openapi-types@12.1.3:
|
||||
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
|
||||
|
||||
openid-client@6.5.0:
|
||||
resolution: {integrity: sha512-fAfYaTnOYE2kQCqEJGX9KDObW2aw7IQy4jWpU/+3D3WoCFLbix5Hg6qIPQ6Js9r7f8jDUmsnnguRNCSw4wU/IQ==}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -2307,6 +2349,10 @@ packages:
|
||||
v8-compile-cache-lib@3.0.1:
|
||||
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
|
||||
|
||||
web-streams-polyfill@3.3.3:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3927,6 +3973,8 @@ snapshots:
|
||||
|
||||
crypto-js@4.2.0: {}
|
||||
|
||||
data-uri-to-buffer@4.0.1: {}
|
||||
|
||||
data-view-buffer@1.0.2:
|
||||
dependencies:
|
||||
call-bound: 1.0.3
|
||||
@@ -4321,6 +4369,11 @@ snapshots:
|
||||
reusify: 1.0.4
|
||||
xtend: 4.0.2
|
||||
|
||||
fetch-blob@3.2.0:
|
||||
dependencies:
|
||||
node-domexception: 1.0.0
|
||||
web-streams-polyfill: 3.3.3
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
dependencies:
|
||||
flat-cache: 4.0.1
|
||||
@@ -4356,6 +4409,10 @@ snapshots:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
|
||||
formdata-polyfill@4.0.10:
|
||||
dependencies:
|
||||
fetch-blob: 3.2.0
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
@@ -4602,6 +4659,10 @@ snapshots:
|
||||
|
||||
javascript-natural-sort@0.7.1: {}
|
||||
|
||||
jose@5.10.0: {}
|
||||
|
||||
jose@6.0.11: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.1.0:
|
||||
@@ -4698,8 +4759,18 @@ snapshots:
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
node-fetch@3.3.2:
|
||||
dependencies:
|
||||
data-uri-to-buffer: 4.0.1
|
||||
fetch-blob: 3.2.0
|
||||
formdata-polyfill: 4.0.10
|
||||
|
||||
nodemailer@6.10.0: {}
|
||||
|
||||
oauth4webapi@3.5.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
object-keys@1.1.1: {}
|
||||
@@ -4739,6 +4810,11 @@ snapshots:
|
||||
|
||||
openapi-types@12.1.3: {}
|
||||
|
||||
openid-client@6.5.0:
|
||||
dependencies:
|
||||
jose: 6.0.11
|
||||
oauth4webapi: 3.5.1
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -5186,6 +5262,8 @@ snapshots:
|
||||
|
||||
v8-compile-cache-lib@3.0.1: {}
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
dependencies:
|
||||
is-bigint: 1.1.0
|
||||
|
@@ -13,7 +13,7 @@ model User {
|
||||
lastName String
|
||||
username String @unique
|
||||
email String @unique
|
||||
password String
|
||||
password String?
|
||||
image String?
|
||||
isAdmin Boolean @default(false)
|
||||
isActive Boolean @default(true)
|
||||
@@ -26,6 +26,7 @@ model User {
|
||||
loginAttempts LoginAttempt?
|
||||
|
||||
passwordResets PasswordReset[]
|
||||
authProviders UserAuthProvider[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -143,3 +144,17 @@ model ShareAlias {
|
||||
|
||||
@@map("share_aliases")
|
||||
}
|
||||
|
||||
model UserAuthProvider {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
provider String
|
||||
providerId String?
|
||||
metadata String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userId, provider])
|
||||
@@map("user_auth_providers")
|
||||
}
|
||||
|
@@ -122,6 +122,61 @@ const defaultConfigs = [
|
||||
value: "3600",
|
||||
type: "number",
|
||||
group: "security",
|
||||
},
|
||||
// OIDC SSO Configurations
|
||||
{
|
||||
key: "oidcEnabled",
|
||||
value: "false",
|
||||
type: "boolean",
|
||||
group: "oidc",
|
||||
},
|
||||
{
|
||||
key: "oidcIssuerUrl",
|
||||
value: "",
|
||||
type: "string",
|
||||
group: "oidc",
|
||||
},
|
||||
{
|
||||
key: "oidcClientId",
|
||||
value: "",
|
||||
type: "string",
|
||||
group: "oidc",
|
||||
},
|
||||
{
|
||||
key: "oidcClientSecret",
|
||||
value: "",
|
||||
type: "string",
|
||||
group: "oidc",
|
||||
},
|
||||
{
|
||||
key: "oidcRedirectUri",
|
||||
value: "",
|
||||
type: "string",
|
||||
group: "oidc",
|
||||
},
|
||||
{
|
||||
key: "oidcScope",
|
||||
value: "openid profile email",
|
||||
type: "string",
|
||||
group: "oidc",
|
||||
},
|
||||
{
|
||||
key: "oidcAutoRegister",
|
||||
value: "true",
|
||||
type: "boolean",
|
||||
group: "oidc",
|
||||
},
|
||||
{
|
||||
key: "oidcAdminEmailDomains",
|
||||
value: "",
|
||||
type: "string",
|
||||
group: "oidc",
|
||||
},
|
||||
{
|
||||
key: "serverUrl",
|
||||
value: "http://localhost:3333",
|
||||
type: "string",
|
||||
group: "general",
|
||||
}
|
||||
];
|
||||
|
||||
@@ -133,7 +188,6 @@ async function main() {
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const config of defaultConfigs) {
|
||||
// Check if configuration already exists
|
||||
const existingConfig = await prisma.appConfig.findUnique({
|
||||
where: { key: config.key },
|
||||
});
|
||||
@@ -144,7 +198,6 @@ async function main() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only create if it doesn't exist
|
||||
await prisma.appConfig.create({
|
||||
data: config,
|
||||
});
|
||||
|
@@ -25,10 +25,11 @@ export async function buildApp() {
|
||||
logger: {
|
||||
level: "info",
|
||||
},
|
||||
bodyLimit: 1024 * 1024 * 1024 * 1024 * 1024, // 1PB limit for body parsing (matches multipart limit)
|
||||
connectionTimeout: 0, // Disable connection timeout
|
||||
keepAliveTimeout: envTimeoutOverrides.keepAliveTimeout, // 20 hours (configurable via env)
|
||||
requestTimeout: envTimeoutOverrides.requestTimeout, // Disabled (configurable via env)
|
||||
bodyLimit: 1024 * 1024 * 1024 * 1024 * 1024,
|
||||
connectionTimeout: 0,
|
||||
keepAliveTimeout: envTimeoutOverrides.keepAliveTimeout,
|
||||
requestTimeout: envTimeoutOverrides.requestTimeout,
|
||||
trustProxy: true,
|
||||
}).withTypeProvider<ZodTypeProvider>();
|
||||
|
||||
app.setValidatorCompiler(validatorCompiler);
|
||||
|
@@ -1,5 +1,3 @@
|
||||
import { env } from "../env";
|
||||
|
||||
/**
|
||||
* Timeout Configuration for Large File Handling
|
||||
*
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import { LogoService } from "./logo.service";
|
||||
import { AppService } from "./service";
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const uploadsDir = path.join(process.cwd(), 'uploads/logo');
|
||||
const uploadsDir = path.join(process.cwd(), "uploads/logo");
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export class AppController {
|
||||
return reply.status(400).send({ error: "No file uploaded" });
|
||||
}
|
||||
|
||||
if (!file.mimetype.startsWith('image/')) {
|
||||
if (!file.mimetype.startsWith("image/")) {
|
||||
return reply.status(400).send({ error: "Only images are allowed" });
|
||||
}
|
||||
|
||||
|
@@ -1,8 +1,7 @@
|
||||
import sharp from "sharp";
|
||||
import { prisma } from "../../shared/prisma";
|
||||
import sharp from "sharp";
|
||||
|
||||
export class LogoService {
|
||||
|
||||
async uploadLogo(buffer: Buffer): Promise<string> {
|
||||
try {
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
@@ -11,20 +10,20 @@ export class LogoService {
|
||||
}
|
||||
|
||||
const webpBuffer = await sharp(buffer)
|
||||
.resize(100, 100, {
|
||||
.resize(100, 100, {
|
||||
fit: "contain",
|
||||
background: { r: 255, g: 255, b: 255, alpha: 0 } // Fundo transparente
|
||||
background: { r: 255, g: 255, b: 255, alpha: 0 },
|
||||
})
|
||||
.webp({
|
||||
.webp({
|
||||
quality: 60,
|
||||
effort: 6,
|
||||
nearLossless: true,
|
||||
alphaQuality: 100, // Melhor qualidade para transparência
|
||||
lossless: true // Preserva melhor a transparência
|
||||
alphaQuality: 100,
|
||||
lossless: true,
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
return `data:image/webp;base64,${webpBuffer.toString('base64')}`;
|
||||
return `data:image/webp;base64,${webpBuffer.toString("base64")}`;
|
||||
} catch (error) {
|
||||
console.error("Error processing logo:", error);
|
||||
throw error;
|
||||
@@ -34,9 +33,9 @@ export class LogoService {
|
||||
async deleteLogo(): Promise<void> {
|
||||
try {
|
||||
await prisma.appConfig.update({
|
||||
where: { key: 'appLogo' },
|
||||
where: { key: "appLogo" },
|
||||
data: {
|
||||
value: '',
|
||||
value: "",
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
@@ -10,13 +10,13 @@ export async function appRoutes(app: FastifyInstance) {
|
||||
const adminPreValidation = async (request: any, reply: any) => {
|
||||
try {
|
||||
const usersCount = await prisma.user.count();
|
||||
|
||||
|
||||
if (usersCount <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
await request.jwtVerify();
|
||||
|
||||
|
||||
if (!request.user.isAdmin) {
|
||||
return reply.status(403).send({
|
||||
error: "Access restricted to administrators",
|
||||
|
@@ -16,7 +16,7 @@ export class AppService {
|
||||
appName,
|
||||
appDescription,
|
||||
appLogo,
|
||||
firstUserAccess : firstUserAccess === "true",
|
||||
firstUserAccess: firstUserAccess === "true",
|
||||
};
|
||||
}
|
||||
|
||||
|
218
apps/server/src/modules/auth/oidc-service.ts
Normal file
218
apps/server/src/modules/auth/oidc-service.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { prisma } from "../../shared/prisma";
|
||||
import { ConfigService } from "../config/service";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
interface PendingState {
|
||||
codeVerifier: string;
|
||||
redirectUrl: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export class OIDCService {
|
||||
private configService = new ConfigService();
|
||||
private initialized = false;
|
||||
private pendingStates = new Map<string, PendingState>();
|
||||
|
||||
async isEnabled() {
|
||||
const oidcEnabled = await this.configService.getValue("oidcEnabled");
|
||||
return oidcEnabled === "true";
|
||||
}
|
||||
|
||||
async getConfiguration() {
|
||||
const oidcIssuerUrl = await this.configService.getValue("oidcIssuerUrl");
|
||||
const oidcClientId = await this.configService.getValue("oidcClientId");
|
||||
const oidcScope = await this.configService.getValue("oidcScope");
|
||||
const oidcRedirectUri = await this.configService.getValue("oidcRedirectUri");
|
||||
|
||||
if (!oidcIssuerUrl || !oidcClientId || !oidcRedirectUri) {
|
||||
return {
|
||||
enabled: false,
|
||||
authUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
const { randomPKCECodeVerifier, calculatePKCECodeChallenge } = await import("openid-client");
|
||||
|
||||
const finalRedirectUri = oidcRedirectUri.replace("localhost:3333", "localhost:3000");
|
||||
const finalState = crypto.randomUUID();
|
||||
const codeVerifier = randomPKCECodeVerifier();
|
||||
const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
|
||||
|
||||
const pendingState: PendingState = {
|
||||
codeVerifier,
|
||||
redirectUrl: "/dashboard",
|
||||
expiresAt: Date.now() + 10 * 60 * 1000,
|
||||
};
|
||||
this.pendingStates.set(finalState, pendingState);
|
||||
|
||||
const authBaseUrl = `${oidcIssuerUrl.replace(/\/$/, "")}/authorize`;
|
||||
const params = new URLSearchParams({
|
||||
client_id: oidcClientId,
|
||||
response_type: "code",
|
||||
scope: oidcScope || "openid profile email",
|
||||
redirect_uri: finalRedirectUri,
|
||||
state: finalState,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
|
||||
const authUrl = `${authBaseUrl}?${params.toString()}`;
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
authUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async handleCallback(code: string, state: string, callbackUrl: string) {
|
||||
const pendingState = this.pendingStates.get(state);
|
||||
if (!pendingState) {
|
||||
throw new Error("Invalid or expired state parameter");
|
||||
}
|
||||
|
||||
if (Date.now() > pendingState.expiresAt) {
|
||||
this.pendingStates.delete(state);
|
||||
throw new Error("State expired");
|
||||
}
|
||||
|
||||
const oidcIssuerUrl = await this.configService.getValue("oidcIssuerUrl");
|
||||
const oidcClientId = await this.configService.getValue("oidcClientId");
|
||||
const oidcClientSecret = await this.configService.getValue("oidcClientSecret");
|
||||
|
||||
if (!oidcIssuerUrl || !oidcClientId || !oidcClientSecret) {
|
||||
throw new Error("OIDC configuration is incomplete");
|
||||
}
|
||||
|
||||
try {
|
||||
const { discovery, authorizationCodeGrant, fetchUserInfo } = await import("openid-client");
|
||||
|
||||
const config = await discovery(new URL(oidcIssuerUrl), oidcClientId, oidcClientSecret);
|
||||
|
||||
const tokens = await authorizationCodeGrant(config, new URL(callbackUrl), {
|
||||
pkceCodeVerifier: pendingState.codeVerifier,
|
||||
expectedState: state,
|
||||
});
|
||||
|
||||
const claims = tokens.claims();
|
||||
|
||||
let userInfo;
|
||||
try {
|
||||
if (tokens.access_token && claims?.sub) {
|
||||
userInfo = await fetchUserInfo(config, tokens.access_token, claims.sub);
|
||||
}
|
||||
} catch (userInfoError) {
|
||||
console.warn("Failed to fetch UserInfo, using ID token claims:", userInfoError);
|
||||
userInfo = claims;
|
||||
}
|
||||
|
||||
const finalUserInfo = userInfo || claims;
|
||||
|
||||
if (!finalUserInfo?.email) {
|
||||
throw new Error("No email found in OIDC response");
|
||||
}
|
||||
|
||||
const user = await this.findOrCreateUser(finalUserInfo);
|
||||
this.pendingStates.delete(state);
|
||||
|
||||
return {
|
||||
user,
|
||||
redirectUrl: pendingState.redirectUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
this.pendingStates.delete(state);
|
||||
console.error("OIDC Callback Error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async findOrCreateUser(userInfo: any) {
|
||||
const email = userInfo.email;
|
||||
const name = userInfo.name || userInfo.given_name || userInfo.preferred_username || email;
|
||||
const oidcSubject = userInfo.sub;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (user) {
|
||||
const existingProvider = await prisma.userAuthProvider.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
provider: "oidc",
|
||||
providerId: oidcSubject,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingProvider) {
|
||||
await prisma.userAuthProvider.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
provider: "oidc",
|
||||
providerId: oidcSubject,
|
||||
metadata: JSON.stringify({
|
||||
email: userInfo.email,
|
||||
name,
|
||||
lastLogin: new Date().toISOString(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.userAuthProvider.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
provider: "oidc",
|
||||
providerId: oidcSubject,
|
||||
},
|
||||
data: {
|
||||
metadata: JSON.stringify({
|
||||
email: userInfo.email,
|
||||
name,
|
||||
lastLogin: new Date().toISOString(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
const oidcAdminEmailDomains = await this.configService.getValue("oidcAdminEmailDomains");
|
||||
const isAdmin = this.isAdminEmail(email, oidcAdminEmailDomains);
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
firstName: name.split(" ")[0] || name,
|
||||
lastName: name.split(" ").slice(1).join(" ") || "",
|
||||
username: email,
|
||||
password: null,
|
||||
isAdmin,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.userAuthProvider.create({
|
||||
data: {
|
||||
userId: newUser.id,
|
||||
provider: "oidc",
|
||||
providerId: oidcSubject,
|
||||
metadata: JSON.stringify({
|
||||
email: userInfo.email,
|
||||
name,
|
||||
lastLogin: new Date().toISOString(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return newUser;
|
||||
}
|
||||
|
||||
private isAdminEmail(email: string, adminDomains: string | null): boolean {
|
||||
if (!adminDomains) return false;
|
||||
|
||||
const domains = adminDomains.split(",").map((domain) => domain.trim().toLowerCase());
|
||||
const emailDomain = email.split("@")[1]?.toLowerCase();
|
||||
|
||||
return domains.includes(emailDomain);
|
||||
}
|
||||
}
|
@@ -45,6 +45,10 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
throw new Error("This account uses external authentication. Please use the appropriate login method.");
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(data.password, user.password);
|
||||
|
||||
if (!isValid) {
|
||||
@@ -75,68 +79,6 @@ export class AuthService {
|
||||
return UserResponseSchema.parse(user);
|
||||
}
|
||||
|
||||
async validateLogin(email: string, password: string) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
include: { loginAttempts: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
if (user.loginAttempts) {
|
||||
const maxAttempts = Number(await this.configService.getValue("maxLoginAttempts"));
|
||||
const blockDurationSeconds = Number(await this.configService.getValue("loginBlockDuration"));
|
||||
const blockDuration = blockDurationSeconds * 1000;
|
||||
|
||||
if (
|
||||
user.loginAttempts.attempts >= maxAttempts &&
|
||||
Date.now() - user.loginAttempts.lastAttempt.getTime() < blockDuration
|
||||
) {
|
||||
const remainingTime = Math.ceil(
|
||||
(blockDuration - (Date.now() - user.loginAttempts.lastAttempt.getTime())) / 1000 / 60
|
||||
);
|
||||
throw new Error(`Too many failed attempts. Please try again in ${remainingTime} minutes.`);
|
||||
}
|
||||
|
||||
if (Date.now() - user.loginAttempts.lastAttempt.getTime() >= blockDuration) {
|
||||
await prisma.loginAttempt.delete({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isValidPassword = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (!isValidPassword) {
|
||||
await prisma.loginAttempt.upsert({
|
||||
where: { userId: user.id },
|
||||
create: {
|
||||
userId: user.id,
|
||||
attempts: 1,
|
||||
lastAttempt: new Date(),
|
||||
},
|
||||
update: {
|
||||
attempts: {
|
||||
increment: 1,
|
||||
},
|
||||
lastAttempt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
if (user.loginAttempts) {
|
||||
await prisma.loginAttempt.delete({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async requestPasswordReset(email: string, origin: string) {
|
||||
const user = await this.userRepository.findUserByEmail(email);
|
||||
if (!user) {
|
||||
|
@@ -98,8 +98,8 @@ export class FilesystemController {
|
||||
} catch (error) {
|
||||
try {
|
||||
await fs.promises.unlink(tempPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
} catch (error) {
|
||||
console.error("Error deleting temp file:", error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
137
apps/server/src/modules/oidc/controller.ts
Normal file
137
apps/server/src/modules/oidc/controller.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { OIDCAuthRequest, OIDCCallback } from "./dto";
|
||||
import { OIDCService } from "./service";
|
||||
import { FastifyRequest, FastifyReply } from "fastify";
|
||||
|
||||
export class OIDCController {
|
||||
private oidcService: OIDCService;
|
||||
|
||||
constructor() {
|
||||
this.oidcService = new OIDCService();
|
||||
}
|
||||
|
||||
async getConfig(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const protocol = (request.headers["x-forwarded-proto"] as string) || request.protocol;
|
||||
const host = (request.headers["x-forwarded-host"] as string) || request.headers.host!;
|
||||
|
||||
const requestContext = {
|
||||
protocol,
|
||||
host,
|
||||
headers: request.headers,
|
||||
};
|
||||
|
||||
const config = await this.oidcService.getConfiguration(requestContext);
|
||||
return reply.send(config);
|
||||
} catch (error) {
|
||||
console.error("Error getting OIDC configuration:", error);
|
||||
return reply.status(500).send({ error: "Failed to get OIDC configuration" });
|
||||
}
|
||||
}
|
||||
|
||||
async authorize(request: FastifyRequest<{ Querystring: OIDCAuthRequest }>, reply: FastifyReply) {
|
||||
try {
|
||||
const isEnabled = await this.oidcService.isEnabled();
|
||||
if (!isEnabled) {
|
||||
return reply.status(400).send({ error: "OIDC is not enabled" });
|
||||
}
|
||||
|
||||
const { state, redirect_uri } = request.query;
|
||||
|
||||
const protocol = (request.headers["x-forwarded-proto"] as string) || request.protocol;
|
||||
const host = (request.headers["x-forwarded-host"] as string) || request.headers.host!;
|
||||
|
||||
const requestContext = {
|
||||
protocol,
|
||||
host,
|
||||
headers: request.headers,
|
||||
};
|
||||
|
||||
const authUrl = await this.oidcService.getAuthorizationUrl(state, redirect_uri, requestContext);
|
||||
return reply.redirect(authUrl);
|
||||
} catch (error) {
|
||||
console.error("Error in OIDC authorize:", error);
|
||||
return reply.status(500).send({ error: "Failed to authorize" });
|
||||
}
|
||||
}
|
||||
|
||||
async callback(request: FastifyRequest<{ Querystring: OIDCCallback }>, reply: FastifyReply) {
|
||||
try {
|
||||
const isEnabled = await this.oidcService.isEnabled();
|
||||
if (!isEnabled) {
|
||||
return reply.status(400).send({ error: "OIDC is not enabled" });
|
||||
}
|
||||
|
||||
const { code, state } = request.query;
|
||||
|
||||
if (!code) {
|
||||
return reply.status(400).send({ error: "Authorization code is required" });
|
||||
}
|
||||
|
||||
const protocol = (request.headers["x-forwarded-proto"] as string) || request.protocol;
|
||||
const host = (request.headers["x-forwarded-host"] as string) || request.headers.host!;
|
||||
const currentUrl = `${protocol}://${host}${request.url}`;
|
||||
|
||||
const { userInfo } = await this.oidcService.handleCallback(code, state, currentUrl);
|
||||
const user = await this.oidcService.findOrCreateUser(userInfo);
|
||||
|
||||
const referer = request.headers.referer;
|
||||
const origin = request.headers.origin;
|
||||
let frontendOrigin;
|
||||
|
||||
if (referer) {
|
||||
const refererUrl = new URL(referer);
|
||||
frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`;
|
||||
} else if (origin) {
|
||||
frontendOrigin = origin;
|
||||
} else {
|
||||
frontendOrigin = `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
const loginUrl = `${frontendOrigin}/login?error=account_inactive`;
|
||||
return reply.redirect(loginUrl);
|
||||
}
|
||||
|
||||
const token = await request.jwtSign({
|
||||
userId: user.id,
|
||||
isAdmin: user.isAdmin,
|
||||
});
|
||||
|
||||
const redirectUrl = `${frontendOrigin}/auth/callback?token=${encodeURIComponent(token)}`;
|
||||
return reply.redirect(redirectUrl);
|
||||
} catch (error) {
|
||||
console.error("OIDC callback error:", error);
|
||||
|
||||
const referer = request.headers.referer;
|
||||
const origin = request.headers.origin;
|
||||
|
||||
const errorProtocol = (request.headers["x-forwarded-proto"] as string) || request.protocol;
|
||||
const errorHost = (request.headers["x-forwarded-host"] as string) || request.headers.host!;
|
||||
let frontendOrigin;
|
||||
|
||||
if (referer) {
|
||||
const refererUrl = new URL(referer);
|
||||
frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`;
|
||||
} else if (origin) {
|
||||
frontendOrigin = origin;
|
||||
} else {
|
||||
frontendOrigin = `${errorProtocol}://${errorHost}`;
|
||||
}
|
||||
|
||||
let errorCode = "auth_failed";
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("registration via OIDC is disabled")) {
|
||||
errorCode = "registration_disabled";
|
||||
} else if (error.message.includes("Invalid or expired")) {
|
||||
errorCode = "token_expired";
|
||||
} else if (error.message.includes("OIDC configuration")) {
|
||||
errorCode = "config_error";
|
||||
}
|
||||
}
|
||||
|
||||
const loginUrl = `${frontendOrigin}/login?error=${errorCode}`;
|
||||
return reply.redirect(loginUrl);
|
||||
}
|
||||
}
|
||||
}
|
36
apps/server/src/modules/oidc/dto.ts
Normal file
36
apps/server/src/modules/oidc/dto.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const OIDCAuthRequestSchema = z.object({
|
||||
state: z.string().optional().describe("OAuth state parameter for CSRF protection"),
|
||||
redirect_uri: z.string().url().optional().describe("Redirect URI after authentication"),
|
||||
});
|
||||
|
||||
export const OIDCCallbackSchema = z.object({
|
||||
code: z.string().describe("Authorization code from OIDC provider"),
|
||||
state: z.string().optional().describe("OAuth state parameter"),
|
||||
});
|
||||
|
||||
export const OIDCConfigResponseSchema = z.object({
|
||||
enabled: z.boolean().describe("Whether OIDC is enabled"),
|
||||
issuer: z.string().optional().describe("OIDC issuer URL"),
|
||||
authUrl: z.string().optional().describe("Authorization URL"),
|
||||
scopes: z.array(z.string()).optional().describe("Available scopes"),
|
||||
});
|
||||
|
||||
export const OIDCUserInfoSchema = z.object({
|
||||
sub: z.string().describe("Subject identifier"),
|
||||
email: z.string().email().optional().describe("User email"),
|
||||
email_verified: z.boolean().optional().describe("Email verification status"),
|
||||
name: z.string().optional().describe("Full name"),
|
||||
given_name: z.string().optional().describe("First name"),
|
||||
family_name: z.string().optional().describe("Last name"),
|
||||
preferred_username: z.string().optional().describe("Preferred username"),
|
||||
picture: z.string().url().optional().describe("Profile picture URL"),
|
||||
groups: z.array(z.string()).optional().describe("User groups"),
|
||||
roles: z.array(z.string()).optional().describe("User roles"),
|
||||
});
|
||||
|
||||
export type OIDCAuthRequest = z.infer<typeof OIDCAuthRequestSchema>;
|
||||
export type OIDCCallback = z.infer<typeof OIDCCallbackSchema>;
|
||||
export type OIDCConfigResponse = z.infer<typeof OIDCConfigResponseSchema>;
|
||||
export type OIDCUserInfo = z.infer<typeof OIDCUserInfoSchema>;
|
12
apps/server/src/modules/oidc/routes.ts
Normal file
12
apps/server/src/modules/oidc/routes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { OIDCController } from "./controller";
|
||||
import { FastifyInstance } from "fastify";
|
||||
|
||||
export async function oidcRoutes(fastify: FastifyInstance) {
|
||||
const oidcController = new OIDCController();
|
||||
|
||||
fastify.get("/config", oidcController.getConfig.bind(oidcController));
|
||||
|
||||
fastify.get("/authorize", oidcController.authorize.bind(oidcController));
|
||||
|
||||
fastify.get("/callback", oidcController.callback.bind(oidcController));
|
||||
}
|
490
apps/server/src/modules/oidc/service.ts
Normal file
490
apps/server/src/modules/oidc/service.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
import { prisma } from "../../shared/prisma";
|
||||
import { ConfigService } from "../config/service";
|
||||
import { OIDCUserInfo } from "./dto";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
if (typeof globalThis.crypto === "undefined") {
|
||||
console.log("🔧 Setting up crypto polyfill for openid-client...");
|
||||
globalThis.crypto = crypto.webcrypto as any;
|
||||
}
|
||||
|
||||
export class OIDCService {
|
||||
private client: any = null;
|
||||
private issuer: any = null;
|
||||
private config: any = null;
|
||||
private initialized = false;
|
||||
private openidClient: any = null;
|
||||
private configService: ConfigService;
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService();
|
||||
this.initializeClient();
|
||||
}
|
||||
|
||||
private async initializeClient() {
|
||||
try {
|
||||
const oidcEnabled = await this.configService.getValue("oidcEnabled");
|
||||
if (!oidcEnabled || oidcEnabled === "false") {
|
||||
return;
|
||||
}
|
||||
|
||||
const oidcIssuerUrl = await this.configService.getValue("oidcIssuerUrl");
|
||||
const oidcClientId = await this.configService.getValue("oidcClientId");
|
||||
const oidcClientSecret = await this.configService.getValue("oidcClientSecret");
|
||||
|
||||
if (!oidcIssuerUrl || !oidcClientId || !oidcClientSecret) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.openidClient = await import("openid-client");
|
||||
|
||||
const issuerUrl = new URL(oidcIssuerUrl);
|
||||
this.config = await this.openidClient.discovery(issuerUrl, oidcClientId, oidcClientSecret);
|
||||
this.issuer = this.config;
|
||||
|
||||
this.client = this.issuer;
|
||||
|
||||
this.initialized = true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing OIDC client:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getRedirectUri(requestContext?: { protocol: string; host: string; headers: any }): Promise<string> {
|
||||
try {
|
||||
const oidcRedirectUri = await this.configService.getValue("oidcRedirectUri");
|
||||
if (oidcRedirectUri) {
|
||||
return oidcRedirectUri;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting redirect URI:", error);
|
||||
}
|
||||
|
||||
if (!requestContext) {
|
||||
throw new Error("Request context is required for OIDC redirect URI auto-detection");
|
||||
}
|
||||
|
||||
const headers = requestContext.headers || {};
|
||||
|
||||
let protocol = requestContext.protocol;
|
||||
if (headers["x-forwarded-proto"]) {
|
||||
protocol = headers["x-forwarded-proto"];
|
||||
}
|
||||
|
||||
let host = requestContext.host;
|
||||
if (headers["x-forwarded-host"]) {
|
||||
host = headers["x-forwarded-host"];
|
||||
}
|
||||
|
||||
const frontendUrl = `${protocol}://${host}`;
|
||||
const redirectUri = `${frontendUrl}/api/auth/oidc/callback`;
|
||||
|
||||
return redirectUri;
|
||||
}
|
||||
|
||||
public async isEnabled(): Promise<boolean> {
|
||||
try {
|
||||
const oidcEnabled = await this.configService.getValue("oidcEnabled");
|
||||
if (oidcEnabled !== "true") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const oidcIssuerUrl = await this.configService.getValue("oidcIssuerUrl");
|
||||
const oidcClientId = await this.configService.getValue("oidcClientId");
|
||||
const oidcClientSecret = await this.configService.getValue("oidcClientSecret");
|
||||
|
||||
return !!(oidcIssuerUrl && oidcClientId && oidcClientSecret);
|
||||
} catch (error) {
|
||||
console.error("Error checking if OIDC is enabled:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async reinitialize(): Promise<void> {
|
||||
this.initialized = false;
|
||||
this.client = null;
|
||||
this.issuer = null;
|
||||
await this.initializeClient();
|
||||
}
|
||||
|
||||
public async getDebugStatus(): Promise<any> {
|
||||
try {
|
||||
const oidcEnabled = await this.configService.getValue("oidcEnabled");
|
||||
const oidcIssuerUrl = await this.configService.getValue("oidcIssuerUrl");
|
||||
const oidcClientId = await this.configService.getValue("oidcClientId");
|
||||
const oidcClientSecret = await this.configService.getValue("oidcClientSecret");
|
||||
|
||||
let initError = null;
|
||||
if (!this.initialized) {
|
||||
try {
|
||||
const openidClient = await import("openid-client");
|
||||
|
||||
const issuerUrl = new URL(oidcIssuerUrl);
|
||||
await openidClient.discovery(issuerUrl, oidcClientId, oidcClientSecret);
|
||||
} catch (err) {
|
||||
initError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
initialized: this.initialized,
|
||||
clientExists: !!this.client,
|
||||
issuerExists: !!this.issuer,
|
||||
initError: initError,
|
||||
config: {
|
||||
enabled: oidcEnabled,
|
||||
issuerUrl: oidcIssuerUrl,
|
||||
clientId: oidcClientId ? "[SET]" : "[NOT SET]",
|
||||
clientSecret: oidcClientSecret ? "[SET]" : "[NOT SET]",
|
||||
},
|
||||
issuerMetadata: this.issuer?.metadata || null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
initialized: this.initialized,
|
||||
clientExists: !!this.client,
|
||||
issuerExists: !!this.issuer,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async getAuthorizationUrl(
|
||||
state?: string,
|
||||
redirectUri?: string,
|
||||
requestContext?: { protocol: string; host: string; headers: any }
|
||||
): Promise<string> {
|
||||
if (!this.initialized) {
|
||||
throw new Error("OIDC client not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
const codeVerifier = this.openidClient.randomPKCECodeVerifier();
|
||||
const codeChallenge = await this.openidClient.calculatePKCECodeChallenge(codeVerifier);
|
||||
|
||||
const sessionId = state || crypto.randomBytes(32).toString("hex");
|
||||
this.storeCodeVerifier(sessionId, codeVerifier);
|
||||
|
||||
const oidcScope = await this.configService.getValue("oidcScope");
|
||||
const defaultRedirectUri = await this.getRedirectUri(requestContext);
|
||||
const finalRedirectUri = redirectUri || defaultRedirectUri;
|
||||
const finalState = sessionId;
|
||||
|
||||
const oidcClientId = await this.configService.getValue("oidcClientId");
|
||||
const oidcIssuerUrl = await this.configService.getValue("oidcIssuerUrl");
|
||||
|
||||
const authBaseUrl = this.config?.authorization_endpoint || `${oidcIssuerUrl.replace(/\/$/, "")}/authorize`;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: oidcClientId,
|
||||
response_type: "code",
|
||||
scope: oidcScope || "openid profile email",
|
||||
redirect_uri: finalRedirectUri,
|
||||
state: finalState,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
|
||||
const authUrl = `${authBaseUrl}?${params.toString()}`;
|
||||
return authUrl;
|
||||
} catch (error) {
|
||||
console.error("Error in getAuthorizationUrl:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async handleCallback(
|
||||
code: string,
|
||||
state?: string,
|
||||
currentUrl?: string
|
||||
): Promise<{
|
||||
userInfo: OIDCUserInfo;
|
||||
tokenSet: any;
|
||||
}> {
|
||||
console.log("🔄 OIDC Service: handleCallback started");
|
||||
console.log("📝 Input params:", {
|
||||
code: code?.substring(0, 20) + "...",
|
||||
state,
|
||||
currentUrl,
|
||||
});
|
||||
|
||||
if (!this.initialized) {
|
||||
console.log("❌ OIDC client not initialized");
|
||||
throw new Error("OIDC client not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("🔐 Getting code verifier for state:", state);
|
||||
const codeVerifier = this.getCodeVerifier(state);
|
||||
console.log("✅ Code verifier retrieved");
|
||||
|
||||
if (!currentUrl) {
|
||||
throw new Error("Current URL is required for OIDC callback handling");
|
||||
}
|
||||
|
||||
const mappedUrl = currentUrl.replace("/auth/oidc/callback", "/api/auth/oidc/callback");
|
||||
const callbackUrl = new URL(mappedUrl);
|
||||
console.log("📍 Callback URL constructed:", callbackUrl.toString());
|
||||
|
||||
const oidcClientId = await this.configService.getValue("oidcClientId");
|
||||
const oidcClientSecret = await this.configService.getValue("oidcClientSecret");
|
||||
console.log("🔑 OIDC credentials:", {
|
||||
clientId: oidcClientId?.substring(0, 10) + "...",
|
||||
hasSecret: !!oidcClientSecret,
|
||||
});
|
||||
|
||||
if (!this.config) {
|
||||
console.log("❌ OIDC configuration not initialized");
|
||||
throw new Error("OIDC configuration not initialized");
|
||||
}
|
||||
console.log("✅ OIDC config available");
|
||||
|
||||
try {
|
||||
console.log("🔄 Exchanging authorization code for tokens...");
|
||||
const tokenSet = await this.openidClient.authorizationCodeGrant(this.config, callbackUrl, {
|
||||
client_id: oidcClientId,
|
||||
client_secret: oidcClientSecret,
|
||||
pkceCodeVerifier: codeVerifier,
|
||||
expectedState: state,
|
||||
});
|
||||
console.log("✅ Token exchange successful");
|
||||
console.log("🎫 Token set received:", {
|
||||
hasAccessToken: !!tokenSet.access_token,
|
||||
hasIdToken: !!tokenSet.id_token,
|
||||
hasRefreshToken: !!tokenSet.refresh_token,
|
||||
});
|
||||
|
||||
let userInfo: any;
|
||||
try {
|
||||
let subject: string;
|
||||
|
||||
if (tokenSet.id_token) {
|
||||
console.log("🔍 Parsing ID token...");
|
||||
const idTokenPayload = JSON.parse(Buffer.from(tokenSet.id_token.split(".")[1], "base64").toString());
|
||||
subject = idTokenPayload.sub;
|
||||
console.log("✅ Subject from ID token:", subject);
|
||||
} else {
|
||||
console.log("❌ ID token not present in response");
|
||||
throw new Error("ID token not present in response");
|
||||
}
|
||||
|
||||
console.log("🔄 Fetching user info...");
|
||||
userInfo = await this.openidClient.fetchUserInfo(this.config, tokenSet.access_token, subject);
|
||||
console.log("✅ User info fetched successfully:", {
|
||||
sub: userInfo.sub,
|
||||
email: userInfo.email,
|
||||
name: userInfo.name,
|
||||
});
|
||||
} catch (userInfoError: any) {
|
||||
console.error("❌ Failed to fetch user info:", userInfoError);
|
||||
throw new Error(`Failed to fetch user info: ${userInfoError.message}`);
|
||||
}
|
||||
|
||||
this.removeCodeVerifier(state);
|
||||
console.log("🧹 Code verifier cleaned up");
|
||||
console.log("✅ OIDC Service: handleCallback completed successfully");
|
||||
return { userInfo, tokenSet };
|
||||
} catch (tokenError: any) {
|
||||
console.error("❌ Token exchange error:", tokenError);
|
||||
console.error("❌ Token error details:", {
|
||||
message: tokenError.message,
|
||||
stack: tokenError.stack,
|
||||
response: tokenError.response,
|
||||
status: tokenError.status,
|
||||
statusText: tokenError.statusText,
|
||||
data: tokenError.data,
|
||||
});
|
||||
throw new Error(`Failed to exchange authorization code: ${tokenError.message}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log("❌ OIDC Service: handleCallback failed");
|
||||
console.error("Error details:", error);
|
||||
this.removeCodeVerifier(state);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async findOrCreateUser(userInfo: OIDCUserInfo): Promise<any> {
|
||||
if (!userInfo.email) {
|
||||
throw new Error("Email is required from OIDC provider");
|
||||
}
|
||||
|
||||
let user = await prisma.user.findUnique({
|
||||
where: { email: userInfo.email },
|
||||
include: { authProviders: true },
|
||||
});
|
||||
|
||||
if (user) {
|
||||
const hasOidcProvider = user.authProviders.some((provider) => provider.provider === "oidc");
|
||||
|
||||
if (!hasOidcProvider) {
|
||||
await prisma.userAuthProvider.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
provider: "oidc",
|
||||
providerId: userInfo.sub,
|
||||
metadata: JSON.stringify({
|
||||
issuer: await this.configService.getValue("oidcIssuerUrl"),
|
||||
email: userInfo.email,
|
||||
name: userInfo.name,
|
||||
picture: userInfo.picture,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
firstName: user.firstName || userInfo.given_name,
|
||||
lastName: user.lastName || userInfo.family_name,
|
||||
image: user.image || userInfo.picture,
|
||||
},
|
||||
include: { authProviders: true },
|
||||
});
|
||||
} else {
|
||||
const oidcAutoRegister = await this.configService.getValue("oidcAutoRegister");
|
||||
if (oidcAutoRegister === "false") {
|
||||
throw new Error("User registration via OIDC is disabled and no existing user found");
|
||||
}
|
||||
|
||||
const isAdmin = await this.isAdminEmail(userInfo.email);
|
||||
|
||||
user = await prisma.$transaction(async (tx) => {
|
||||
const newUser = await tx.user.create({
|
||||
data: {
|
||||
email: userInfo.email!,
|
||||
firstName: userInfo.given_name || userInfo.name?.split(" ")[0] || "Unknown",
|
||||
lastName: userInfo.family_name || userInfo.name?.split(" ").slice(1).join(" ") || "User",
|
||||
username: userInfo.preferred_username || userInfo.email!.split("@")[0],
|
||||
isAdmin,
|
||||
isActive: true,
|
||||
image: userInfo.picture || null,
|
||||
password: null,
|
||||
},
|
||||
include: { authProviders: true },
|
||||
});
|
||||
|
||||
await tx.userAuthProvider.create({
|
||||
data: {
|
||||
userId: newUser.id,
|
||||
provider: "oidc",
|
||||
providerId: userInfo.sub,
|
||||
metadata: JSON.stringify({
|
||||
issuer: await this.configService.getValue("oidcIssuerUrl"),
|
||||
email: userInfo.email,
|
||||
name: userInfo.name,
|
||||
picture: userInfo.picture,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return newUser;
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private async isAdminEmail(email: string): Promise<boolean> {
|
||||
try {
|
||||
const oidcAdminEmailDomains = await this.configService.getValue("oidcAdminEmailDomains");
|
||||
if (!oidcAdminEmailDomains) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const adminDomains = oidcAdminEmailDomains.split(",").map((d) => d.trim());
|
||||
const emailDomain = email.split("@")[1];
|
||||
|
||||
return adminDomains.includes(emailDomain);
|
||||
} catch (error) {
|
||||
console.error("Error checking admin email:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async getConfiguration(requestContext?: { protocol: string; host: string; headers: any }) {
|
||||
try {
|
||||
if (!requestContext) {
|
||||
throw new Error("Request context is required for OIDC configuration");
|
||||
}
|
||||
|
||||
const enabled = await this.isEnabled();
|
||||
const oidcIssuerUrl = await this.configService.getValue("oidcIssuerUrl");
|
||||
const oidcScope = await this.configService.getValue("oidcScope");
|
||||
|
||||
let authUrl: string | undefined;
|
||||
if (enabled) {
|
||||
try {
|
||||
if (this.initialized && this.client) {
|
||||
authUrl = await this.getAuthorizationUrl(undefined, undefined, requestContext);
|
||||
} else {
|
||||
const { protocol, host } = requestContext;
|
||||
const baseUrl = `${protocol}://${host}`;
|
||||
authUrl = `${baseUrl}/api/auth/oidc/authorize`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error generating authorization URL:", error);
|
||||
const { protocol, host } = requestContext;
|
||||
const baseUrl = `${protocol}://${host}`;
|
||||
authUrl = `${baseUrl}/api/auth/oidc/authorize`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
issuer: oidcIssuerUrl,
|
||||
authUrl,
|
||||
scopes: oidcScope?.split(" ") || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error getting configuration:", error);
|
||||
return {
|
||||
enabled: false,
|
||||
issuer: undefined,
|
||||
authUrl: undefined,
|
||||
scopes: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private codeVerifiers = new Map<string, { verifier: string; timestamp: number }>();
|
||||
|
||||
private storeCodeVerifier(sessionId: string, verifier: string) {
|
||||
this.codeVerifiers.set(sessionId, {
|
||||
verifier,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
const entry = this.codeVerifiers.get(sessionId);
|
||||
if (entry && Date.now() - entry.timestamp > 10 * 60 * 1000) {
|
||||
this.codeVerifiers.delete(sessionId);
|
||||
}
|
||||
},
|
||||
10 * 60 * 1000
|
||||
);
|
||||
}
|
||||
|
||||
private getCodeVerifier(sessionId?: string): string {
|
||||
if (!sessionId) {
|
||||
throw new Error("Session ID is required");
|
||||
}
|
||||
|
||||
const entry = this.codeVerifiers.get(sessionId);
|
||||
if (!entry) {
|
||||
const newVerifier = this.openidClient.randomPKCECodeVerifier();
|
||||
this.storeCodeVerifier(sessionId, newVerifier);
|
||||
return newVerifier;
|
||||
}
|
||||
|
||||
return entry.verifier;
|
||||
}
|
||||
|
||||
private removeCodeVerifier(sessionId?: string) {
|
||||
if (sessionId) {
|
||||
this.codeVerifiers.delete(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
@@ -51,7 +51,6 @@ export class FilesystemStorageProvider implements StorageProvider {
|
||||
}
|
||||
|
||||
public getFilePath(objectName: string): string {
|
||||
// Sanitize the object name to prevent directory traversal
|
||||
const sanitizedName = objectName.replace(/[^a-zA-Z0-9\-_./]/g, "_");
|
||||
return path.join(this.uploadsDir, sanitizedName);
|
||||
}
|
||||
|
@@ -2,17 +2,28 @@ import { buildApp } from "./app";
|
||||
import { env } from "./env";
|
||||
import { appRoutes } from "./modules/app/routes";
|
||||
import { authRoutes } from "./modules/auth/routes";
|
||||
import { ConfigService } from "./modules/config/service";
|
||||
import { fileRoutes } from "./modules/file/routes";
|
||||
import { filesystemRoutes } from "./modules/filesystem/routes";
|
||||
import { healthRoutes } from "./modules/health/routes";
|
||||
import { oidcRoutes } from "./modules/oidc/routes";
|
||||
import { shareRoutes } from "./modules/share/routes";
|
||||
import { storageRoutes } from "./modules/storage/routes";
|
||||
import { userRoutes } from "./modules/user/routes";
|
||||
import fastifyMultipart from "@fastify/multipart";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import * as fs from "fs/promises";
|
||||
import crypto from "node:crypto";
|
||||
import path from "path";
|
||||
|
||||
if (typeof globalThis.crypto === "undefined") {
|
||||
globalThis.crypto = crypto.webcrypto as any;
|
||||
}
|
||||
|
||||
if (typeof global.crypto === "undefined") {
|
||||
(global as any).crypto = crypto.webcrypto;
|
||||
}
|
||||
|
||||
async function ensureDirectories() {
|
||||
const uploadsDir = path.join(process.cwd(), "uploads");
|
||||
const tempChunksDir = path.join(process.cwd(), "temp-chunks");
|
||||
@@ -34,13 +45,14 @@ async function ensureDirectories() {
|
||||
|
||||
async function startServer() {
|
||||
const app = await buildApp();
|
||||
const configService = new ConfigService();
|
||||
|
||||
await ensureDirectories();
|
||||
|
||||
await app.register(fastifyMultipart, {
|
||||
limits: {
|
||||
fieldNameSize: 100,
|
||||
fieldSize: 1024 * 1024, // 1MB for chunk metadata
|
||||
fieldSize: 1024 * 1024,
|
||||
fields: 10,
|
||||
fileSize: 1024 * 1024 * 1024 * 1024 * 1024, // 1PB (1 petabyte) - practically unlimited
|
||||
files: 1,
|
||||
@@ -57,6 +69,7 @@ async function startServer() {
|
||||
}
|
||||
|
||||
app.register(authRoutes);
|
||||
app.register(oidcRoutes, { prefix: "/auth/oidc" });
|
||||
app.register(userRoutes);
|
||||
app.register(fileRoutes);
|
||||
|
||||
@@ -74,8 +87,17 @@ async function startServer() {
|
||||
host: "0.0.0.0",
|
||||
});
|
||||
|
||||
let oidcStatus = "Disabled";
|
||||
try {
|
||||
const oidcEnabled = await configService.getValue("oidcEnabled");
|
||||
oidcStatus = oidcEnabled === "true" ? "Enabled" : "Disabled";
|
||||
} catch (error) {
|
||||
console.error("Error getting OIDC status:", error);
|
||||
}
|
||||
|
||||
console.log(`🌴 Palmr server running on port 3333 🌴`);
|
||||
console.log(`📦 Storage mode: ${env.ENABLE_S3 === "true" ? "S3" : "Local Filesystem (Encrypted)"}`);
|
||||
console.log(`🔐 OIDC SSO: ${oidcStatus}`);
|
||||
|
||||
console.log("\n📚 API Documentation:");
|
||||
console.log(` - API Reference: http://localhost:3333/docs\n`);
|
||||
|
@@ -207,7 +207,10 @@
|
||||
"signIn": "تسجيل الدخول",
|
||||
"signingIn": "جاري تسجيل الدخول...",
|
||||
"forgotPassword": "نسيت كلمة المرور؟",
|
||||
"pageTitle": "تسجيل الدخول"
|
||||
"pageTitle": "تسجيل الدخول",
|
||||
"or": "أو",
|
||||
"continueWithSSO": "متابعة مع تسجيل الدخول الموحد",
|
||||
"processing": "جاري معالجة المصادقة..."
|
||||
},
|
||||
"logo": {
|
||||
"labels": {
|
||||
@@ -402,6 +405,10 @@
|
||||
"storage": {
|
||||
"title": "التخزين",
|
||||
"description": "تكوين تخزين الملفات"
|
||||
},
|
||||
"oidc": {
|
||||
"title": "OpenID Connect (SSO)",
|
||||
"description": "Configuração de autenticação SSO via OpenID Connect"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
@@ -477,6 +484,42 @@
|
||||
"firstUserAccess": {
|
||||
"description": "إعدادات الوصول الأول للمستخدمين الجدد",
|
||||
"title": "الوصول الأول للمستخدم"
|
||||
},
|
||||
"serverUrl": {
|
||||
"title": "رابط الخادم",
|
||||
"description": "الرابط الأساسي لخادم Palmr (مثال: https://palmr.example.com)"
|
||||
},
|
||||
"oidcEnabled": {
|
||||
"title": "OIDC مفعل",
|
||||
"description": "تفعيل أو إلغاء تفعيل المصادقة عبر OpenID Connect"
|
||||
},
|
||||
"oidcIssuerUrl": {
|
||||
"title": "رابط المزود",
|
||||
"description": "رابط مزود OpenID Connect (مثال: https://auth.example.com/auth/realms/master)"
|
||||
},
|
||||
"oidcClientId": {
|
||||
"title": "معرف العميل",
|
||||
"description": "معرف عميل OIDC المسجل لدى المزود"
|
||||
},
|
||||
"oidcClientSecret": {
|
||||
"title": "سر العميل",
|
||||
"description": "سر عميل OIDC للمصادقة"
|
||||
},
|
||||
"oidcRedirectUri": {
|
||||
"title": "رابط إعادة التوجيه",
|
||||
"description": "الرابط الذي سيتم توجيه المستخدم إليه بعد المصادقة. أدخل الرابط الأساسي فقط (مثال: https://mysite.com). سيتم إضافة المسار /api/auth/oidc/callback تلقائياً."
|
||||
},
|
||||
"oidcScope": {
|
||||
"title": "النطاقات",
|
||||
"description": "نطاقات OIDC المطلوبة (مثال: openid profile email)"
|
||||
},
|
||||
"oidcAutoRegister": {
|
||||
"title": "التسجيل التلقائي",
|
||||
"description": "تسجيل المستخدمين الذين لا يوجدون تلقائياً في أول تسجيل دخول"
|
||||
},
|
||||
"oidcAdminEmailDomains": {
|
||||
"title": "نطاقات البريد الإلكتروني للمشرف",
|
||||
"description": "نطاقات البريد الإلكتروني التي تحصل على صلاحيات المشرف تلقائياً"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -492,7 +535,15 @@
|
||||
},
|
||||
"title": "الإعدادات",
|
||||
"breadcrumb": "الإعدادات",
|
||||
"pageTitle": "الإعدادات"
|
||||
"pageTitle": "الإعدادات",
|
||||
"tooltips": {
|
||||
"oidcScope": "أدخل نطاقاً واضغط Enter للإضافة",
|
||||
"oidcAdminEmailDomains": "أدخل نطاقاً واضغط Enter للإضافة"
|
||||
},
|
||||
"redirectUri": {
|
||||
"placeholder": "https://mysite.com",
|
||||
"previewLabel": "الرابط الكامل الذي سيتم حفظه:"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"errors": {
|
||||
@@ -848,7 +899,7 @@
|
||||
},
|
||||
"passwordRequirements": {
|
||||
"title": "متطلبات كلمة المرور:",
|
||||
"minLength": "على الأقل حرفان"
|
||||
"minLength": "على الأقل حرفين"
|
||||
},
|
||||
"newPassword": "كلمة مرور جديدة",
|
||||
"success": {
|
||||
@@ -891,5 +942,14 @@
|
||||
},
|
||||
"expires": "تنتهي صلاحيته:",
|
||||
"expirationDate": "تاريخ انتهاء الصلاحية"
|
||||
},
|
||||
"auth": {
|
||||
"errors": {
|
||||
"account_inactive": "الحساب غير نشط. اتصل بالمشرف.",
|
||||
"registration_disabled": "التسجيل عبر تسجيل الدخول الموحد معطل.",
|
||||
"token_expired": "انتهت صلاحية الرمز المميز. حاول مرة أخرى.",
|
||||
"config_error": "خطأ في التكوين. اتصل بالدعم الفني.",
|
||||
"auth_failed": "فشل في المصادقة. حاول مرة أخرى."
|
||||
}
|
||||
}
|
||||
}
|
@@ -207,7 +207,10 @@
|
||||
"signIn": "Anmelden",
|
||||
"signingIn": "Melde an...",
|
||||
"forgotPassword": "Passwort vergessen?",
|
||||
"pageTitle": "Anmeldung"
|
||||
"pageTitle": "Anmeldung",
|
||||
"or": "oder",
|
||||
"continueWithSSO": "Mit SSO fortfahren",
|
||||
"processing": "Authentifizierung wird verarbeitet..."
|
||||
},
|
||||
"logo": {
|
||||
"labels": {
|
||||
@@ -402,6 +405,10 @@
|
||||
"storage": {
|
||||
"title": "Speicher",
|
||||
"description": "Konfiguration des Dateispeichers"
|
||||
},
|
||||
"oidc": {
|
||||
"title": "OpenID Connect (SSO)",
|
||||
"description": "SSO-Authentifizierung über OpenID Connect konfigurieren"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
@@ -477,6 +484,42 @@
|
||||
"firstUserAccess": {
|
||||
"description": "Einstellungen für den ersten Zugriff neuer Benutzer",
|
||||
"title": "Erster Benutzerzugriff"
|
||||
},
|
||||
"serverUrl": {
|
||||
"title": "Server-URL",
|
||||
"description": "Basis-URL des Palmr-Servers (z.B.: https://palmr.example.com)"
|
||||
},
|
||||
"oidcEnabled": {
|
||||
"title": "OIDC aktiviert",
|
||||
"description": "Authentifizierung über OpenID Connect aktivieren oder deaktivieren"
|
||||
},
|
||||
"oidcIssuerUrl": {
|
||||
"title": "Anbieter-URL",
|
||||
"description": "URL des OpenID Connect-Anbieters (z.B.: https://auth.example.com/auth/realms/master)"
|
||||
},
|
||||
"oidcClientId": {
|
||||
"title": "Client-ID",
|
||||
"description": "Identifier des beim Anbieter registrierten OIDC-Clients"
|
||||
},
|
||||
"oidcClientSecret": {
|
||||
"title": "Client-Secret",
|
||||
"description": "Secret des OIDC-Clients für die Authentifizierung"
|
||||
},
|
||||
"oidcRedirectUri": {
|
||||
"title": "Weiterleitungs-URI",
|
||||
"description": "URI, zu der der Benutzer nach der Authentifizierung weitergeleitet wird. Geben Sie nur die Basis-URL ein (z.B.: https://meineseite.com). Der Pfad /api/auth/oidc/callback wird automatisch hinzugefügt."
|
||||
},
|
||||
"oidcScope": {
|
||||
"title": "Scopes",
|
||||
"description": "Angeforderte OIDC-Scopes (z.B.: openid profile email)"
|
||||
},
|
||||
"oidcAutoRegister": {
|
||||
"title": "Automatische Registrierung",
|
||||
"description": "Registriert automatisch Benutzer, die beim ersten Login nicht existieren"
|
||||
},
|
||||
"oidcAdminEmailDomains": {
|
||||
"title": "Admin-E-Mail-Domains",
|
||||
"description": "E-Mail-Domains, die automatisch Administratorrechte erhalten"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -492,7 +535,15 @@
|
||||
},
|
||||
"title": "Einstellungen",
|
||||
"breadcrumb": "Einstellungen",
|
||||
"pageTitle": "Einstellungen"
|
||||
"pageTitle": "Einstellungen",
|
||||
"tooltips": {
|
||||
"oidcScope": "Geben Sie einen Scope ein und drücken Sie Enter zum Hinzufügen",
|
||||
"oidcAdminEmailDomains": "Geben Sie eine Domain ein und drücken Sie Enter zum Hinzufügen"
|
||||
},
|
||||
"redirectUri": {
|
||||
"placeholder": "https://meineseite.com",
|
||||
"previewLabel": "Vollständige URL, die gespeichert wird:"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"errors": {
|
||||
@@ -891,5 +942,14 @@
|
||||
},
|
||||
"expires": "Läuft ab:",
|
||||
"expirationDate": "Ablaufdatum"
|
||||
},
|
||||
"auth": {
|
||||
"errors": {
|
||||
"account_inactive": "Konto inaktiv. Kontaktieren Sie den Administrator.",
|
||||
"registration_disabled": "Registrierung über SSO ist deaktiviert.",
|
||||
"token_expired": "Token abgelaufen. Versuchen Sie es erneut.",
|
||||
"config_error": "Konfigurationsfehler. Kontaktieren Sie den Support.",
|
||||
"auth_failed": "Authentifizierung fehlgeschlagen. Versuchen Sie es erneut."
|
||||
}
|
||||
}
|
||||
}
|
@@ -58,7 +58,7 @@
|
||||
"descriptionLabel": "Description",
|
||||
"descriptionPlaceholder": "Enter file description",
|
||||
"deleteFile": "Delete File",
|
||||
"deleteConfirmation": "Are you sure you want to delete ?",
|
||||
"deleteConfirmation": "Are you sure you want to delete this file?",
|
||||
"deleteWarning": "This action cannot be undone."
|
||||
},
|
||||
"fileManager": {
|
||||
@@ -71,32 +71,32 @@
|
||||
"filePreview": {
|
||||
"title": "Preview File",
|
||||
"loading": "Loading...",
|
||||
"notAvailable": "Preview not available for this file type.",
|
||||
"downloadToView": "Use the download button to download the file.",
|
||||
"loadError": "Error loading file preview.",
|
||||
"downloadError": "Error downloading file.",
|
||||
"audioNotSupported": "Your browser does not support the audio element.",
|
||||
"videoNotSupported": "Your browser does not support the video element.",
|
||||
"pdfPreviewNotAvailable": "PDF preview is not available. Try alternative view or download.",
|
||||
"notAvailable": "Preview not available for this file type",
|
||||
"downloadToView": "Use the download button to view this file",
|
||||
"loadError": "Error loading file preview",
|
||||
"downloadError": "Error downloading file",
|
||||
"audioNotSupported": "Your browser doesn't support audio playback",
|
||||
"videoNotSupported": "Your browser doesn't support video playback",
|
||||
"pdfPreviewNotAvailable": "PDF preview unavailable. Try alternative view or download",
|
||||
"tryAlternativeView": "Try Alternative View",
|
||||
"loadingAlternative": "Loading alternative view...",
|
||||
"loadingAudio": "Loading audio..."
|
||||
},
|
||||
"fileSelector": {
|
||||
"availableFiles": "Available Files ({count})",
|
||||
"shareFiles": "Share Files ({count})",
|
||||
"shareFilesDescription": "Files currently in the share",
|
||||
"availableFilesDescription": "Select files to add to the share",
|
||||
"shareFiles": "Shared Files ({count})",
|
||||
"shareFilesDescription": "Files currently in this share",
|
||||
"availableFilesDescription": "Select files to add to this share",
|
||||
"searchPlaceholder": "Search files...",
|
||||
"searchSelectedFiles": "Search selected files...",
|
||||
"noMatchingFiles": "No matching files",
|
||||
"noMatchingFiles": "No matching files found",
|
||||
"noAvailableFiles": "No files available",
|
||||
"noFilesInShare": "No files in share",
|
||||
"noFilesInShare": "No files in this share",
|
||||
"noFilesFound": "No files found",
|
||||
"noFilesFoundWith": "No files found with \"{query}\"",
|
||||
"noFilesFoundWith": "No files found matching \"{query}\"",
|
||||
"addFilesFromList": "Add files from the list below",
|
||||
"tryDifferentSearch": "Try different search terms",
|
||||
"allFilesInShare": "All files are already in the share",
|
||||
"allFilesInShare": "All files are already in this share",
|
||||
"uploadNewFiles": "Upload new files to add them",
|
||||
"fileCount": "{count, plural, =1 {file} other {files}}",
|
||||
"filesSelected": "{count, plural, =0 {No files selected} =1 {1 file selected} other {# files selected}}",
|
||||
@@ -168,7 +168,7 @@
|
||||
"submit": "Send Reset Instructions",
|
||||
"backToLogin": "Back to Login",
|
||||
"title": "Forgot Password",
|
||||
"description": "Enter your email address and we'll send you instructions to reset your password.",
|
||||
"description": "Enter your email address and we'll send you instructions to reset your password",
|
||||
"resetInstructions": "Reset instructions sent to your email",
|
||||
"pageTitle": "Forgot Password"
|
||||
},
|
||||
@@ -187,10 +187,10 @@
|
||||
"copied": "Link copied to clipboard"
|
||||
},
|
||||
"home": {
|
||||
"description": "The open-source alternative to WeTransfer. Share files securely, without tracking, or limitations.",
|
||||
"description": "The open-source alternative to WeTransfer. Share files securely, without tracking or limitations.",
|
||||
"documentation": "Documentation",
|
||||
"starOnGithub": "Star on GitHub",
|
||||
"privacyMessage": "Built with privacy in mind. Your files before upload only accessible by those with the sharing link. Forever free and open source.",
|
||||
"privacyMessage": "Built with privacy in mind. Your files are only accessible to those with the sharing link before upload. Forever free and open source.",
|
||||
"header": {
|
||||
"fileSharing": "File sharing",
|
||||
"tagline": "made simple and free"
|
||||
@@ -207,7 +207,10 @@
|
||||
"signIn": "Sign In",
|
||||
"signingIn": "Signing in...",
|
||||
"forgotPassword": "Forgot password?",
|
||||
"pageTitle": "Login"
|
||||
"pageTitle": "Login",
|
||||
"or": "or",
|
||||
"continueWithSSO": "Continue with SSO",
|
||||
"processing": "Processing authentication..."
|
||||
},
|
||||
"logo": {
|
||||
"labels": {
|
||||
@@ -231,7 +234,7 @@
|
||||
"profileMenu": "Profile Menu",
|
||||
"profile": "Profile",
|
||||
"settings": "Settings",
|
||||
"usersManagement": "Users Management",
|
||||
"usersManagement": "User Management",
|
||||
"logout": "Log Out"
|
||||
},
|
||||
"navigation": {
|
||||
@@ -402,6 +405,10 @@
|
||||
"storage": {
|
||||
"title": "Storage",
|
||||
"description": "File storage configuration"
|
||||
},
|
||||
"oidc": {
|
||||
"title": "OpenID Connect (SSO)",
|
||||
"description": "Configure SSO authentication via OpenID Connect"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
@@ -477,6 +484,42 @@
|
||||
"firstUserAccess": {
|
||||
"title": "First User Access",
|
||||
"description": "Settings for first access of new users"
|
||||
},
|
||||
"serverUrl": {
|
||||
"title": "Server URL",
|
||||
"description": "Base URL of the Palmr server (e.g.: https://palmr.example.com)"
|
||||
},
|
||||
"oidcEnabled": {
|
||||
"title": "OIDC Enabled",
|
||||
"description": "Enable or disable OpenID Connect authentication"
|
||||
},
|
||||
"oidcIssuerUrl": {
|
||||
"title": "Provider URL",
|
||||
"description": "OpenID Connect provider URL (e.g.: https://auth.example.com/auth/realms/master)"
|
||||
},
|
||||
"oidcClientId": {
|
||||
"title": "Client ID",
|
||||
"description": "OIDC client identifier registered with the provider"
|
||||
},
|
||||
"oidcClientSecret": {
|
||||
"title": "Client Secret",
|
||||
"description": "OIDC client secret for authentication"
|
||||
},
|
||||
"oidcRedirectUri": {
|
||||
"title": "Redirect URI",
|
||||
"description": "URI where users will be redirected after authentication. Enter only the base URL (e.g.: https://mysite.com). The path /api/auth/oidc/callback will be added automatically."
|
||||
},
|
||||
"oidcScope": {
|
||||
"title": "Scopes",
|
||||
"description": "Requested OIDC scopes (e.g.: openid profile email)"
|
||||
},
|
||||
"oidcAutoRegister": {
|
||||
"title": "Auto Register",
|
||||
"description": "Automatically register users that don't exist on first login"
|
||||
},
|
||||
"oidcAdminEmailDomains": {
|
||||
"title": "Admin Email Domains",
|
||||
"description": "Email domains that automatically receive admin privileges"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -492,7 +535,15 @@
|
||||
},
|
||||
"title": "Settings",
|
||||
"breadcrumb": "Settings",
|
||||
"pageTitle": "Settings"
|
||||
"pageTitle": "Settings",
|
||||
"tooltips": {
|
||||
"oidcScope": "Enter a scope and press Enter to add",
|
||||
"oidcAdminEmailDomains": "Enter a domain and press Enter to add"
|
||||
},
|
||||
"redirectUri": {
|
||||
"placeholder": "https://mysite.com",
|
||||
"previewLabel": "Complete URL that will be saved:"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"errors": {
|
||||
@@ -891,5 +942,14 @@
|
||||
"totalSize": "Total size",
|
||||
"creating": "Creating...",
|
||||
"create": "Create Share"
|
||||
},
|
||||
"auth": {
|
||||
"errors": {
|
||||
"account_inactive": "Account inactive. Please contact the administrator.",
|
||||
"registration_disabled": "SSO registration is disabled.",
|
||||
"token_expired": "Token expired. Please try again.",
|
||||
"config_error": "Configuration error. Please contact support.",
|
||||
"auth_failed": "Authentication failed. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
@@ -207,7 +207,10 @@
|
||||
"signIn": "Iniciar sesión",
|
||||
"signingIn": "Iniciando sesión...",
|
||||
"forgotPassword": "¿Olvidaste tu contraseña?",
|
||||
"pageTitle": "Iniciar sesión"
|
||||
"pageTitle": "Iniciar sesión",
|
||||
"or": "ou",
|
||||
"continueWithSSO": "Continuar com SSO",
|
||||
"processing": "Processando autenticação..."
|
||||
},
|
||||
"logo": {
|
||||
"labels": {
|
||||
@@ -402,6 +405,10 @@
|
||||
"storage": {
|
||||
"title": "Almacenamiento",
|
||||
"description": "Configuración del almacenamiento de archivos"
|
||||
},
|
||||
"oidc": {
|
||||
"title": "OpenID Connect (SSO)",
|
||||
"description": "Configuración de autenticación SSO mediante OpenID Connect"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
@@ -477,6 +484,42 @@
|
||||
"firstUserAccess": {
|
||||
"description": "Configuraciones para el primer acceso de nuevos usuarios",
|
||||
"title": "Primer Acceso de Usuario"
|
||||
},
|
||||
"serverUrl": {
|
||||
"title": "URL del Servidor",
|
||||
"description": "URL base del servidor Palmr (ej: https://palmr.ejemplo.com)"
|
||||
},
|
||||
"oidcEnabled": {
|
||||
"title": "OIDC Habilitado",
|
||||
"description": "Activar o desactivar la autenticación mediante OpenID Connect"
|
||||
},
|
||||
"oidcIssuerUrl": {
|
||||
"title": "URL del Proveedor",
|
||||
"description": "URL del proveedor OpenID Connect (ej: https://auth.ejemplo.com/auth/realms/master)"
|
||||
},
|
||||
"oidcClientId": {
|
||||
"title": "ID del Cliente",
|
||||
"description": "Identificador del cliente OIDC registrado en el proveedor"
|
||||
},
|
||||
"oidcClientSecret": {
|
||||
"title": "Secreto del Cliente",
|
||||
"description": "Secreto del cliente OIDC para autenticación"
|
||||
},
|
||||
"oidcRedirectUri": {
|
||||
"title": "URI de Redirección",
|
||||
"description": "URI donde los usuarios serán redirigidos después de la autenticación. Ingrese solo la URL base (ej: https://misitio.com). La ruta /api/auth/oidc/callback se agregará automáticamente."
|
||||
},
|
||||
"oidcScope": {
|
||||
"title": "Ámbitos",
|
||||
"description": "Ámbitos OIDC solicitados (ej: openid profile email)"
|
||||
},
|
||||
"oidcAutoRegister": {
|
||||
"title": "Registro Automático",
|
||||
"description": "Registra automáticamente usuarios que no existen en el primer inicio de sesión"
|
||||
},
|
||||
"oidcAdminEmailDomains": {
|
||||
"title": "Dominios de Correo Admin",
|
||||
"description": "Dominios de correo que reciben privilegios de administrador automáticamente"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -492,7 +535,15 @@
|
||||
},
|
||||
"title": "Configuración",
|
||||
"breadcrumb": "Configuración",
|
||||
"pageTitle": "Configuración"
|
||||
"pageTitle": "Configuración",
|
||||
"tooltips": {
|
||||
"oidcScope": "Ingrese un ámbito y presione Enter para agregar",
|
||||
"oidcAdminEmailDomains": "Ingrese un dominio y presione Enter para agregar"
|
||||
},
|
||||
"redirectUri": {
|
||||
"placeholder": "https://misitio.com",
|
||||
"previewLabel": "URL completa que se guardará:"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"errors": {
|
||||
@@ -891,5 +942,14 @@
|
||||
},
|
||||
"expires": "Expira:",
|
||||
"expirationDate": "Fecha de Expiración"
|
||||
},
|
||||
"auth": {
|
||||
"errors": {
|
||||
"account_inactive": "Cuenta inactiva. Por favor, contacte al administrador.",
|
||||
"registration_disabled": "El registro mediante SSO está deshabilitado.",
|
||||
"token_expired": "Token expirado. Por favor, inténtelo de nuevo.",
|
||||
"config_error": "Error de configuración. Por favor, contacte al soporte.",
|
||||
"auth_failed": "Error de autenticación. Por favor, inténtelo de nuevo."
|
||||
}
|
||||
}
|
||||
}
|
@@ -207,7 +207,10 @@
|
||||
"signIn": "Se connecter",
|
||||
"signingIn": "Connexion en cours...",
|
||||
"forgotPassword": "Mot de passe oublié ?",
|
||||
"pageTitle": "Connexion"
|
||||
"pageTitle": "Connexion",
|
||||
"or": "ou",
|
||||
"continueWithSSO": "Continuar com SSO",
|
||||
"processing": "Processando autenticação..."
|
||||
},
|
||||
"logo": {
|
||||
"labels": {
|
||||
@@ -405,6 +408,10 @@
|
||||
"storage": {
|
||||
"title": "Stockage",
|
||||
"description": "Configuration du stockage des fichiers"
|
||||
},
|
||||
"oidc": {
|
||||
"title": "OpenID Connect (SSO)",
|
||||
"description": "Configuration de l'authentification SSO via OpenID Connect"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
@@ -480,6 +487,42 @@
|
||||
"firstUserAccess": {
|
||||
"description": "Paramètres pour le premier accès des nouveaux utilisateurs",
|
||||
"title": "Premier Accès Utilisateur"
|
||||
},
|
||||
"serverUrl": {
|
||||
"title": "URL du Serveur",
|
||||
"description": "URL de base du serveur Palmr (ex: https://palmr.exemple.com)"
|
||||
},
|
||||
"oidcEnabled": {
|
||||
"title": "OIDC Activé",
|
||||
"description": "Activer ou désactiver l'authentification via OpenID Connect"
|
||||
},
|
||||
"oidcIssuerUrl": {
|
||||
"title": "URL du Fournisseur",
|
||||
"description": "URL du fournisseur OpenID Connect (ex: https://auth.exemple.com/auth/realms/master)"
|
||||
},
|
||||
"oidcClientId": {
|
||||
"title": "ID Client",
|
||||
"description": "Identifiant du client OIDC enregistré auprès du fournisseur"
|
||||
},
|
||||
"oidcClientSecret": {
|
||||
"title": "Secret Client",
|
||||
"description": "Secret du client OIDC pour l'authentification"
|
||||
},
|
||||
"oidcRedirectUri": {
|
||||
"title": "URI de Redirection",
|
||||
"description": "URI vers laquelle les utilisateurs seront redirigés après l'authentification. Entrez uniquement l'URL de base (ex: https://monsite.com). Le chemin /api/auth/oidc/callback sera ajouté automatiquement."
|
||||
},
|
||||
"oidcScope": {
|
||||
"title": "Portées",
|
||||
"description": "Portées OIDC demandées (ex: openid profile email)"
|
||||
},
|
||||
"oidcAutoRegister": {
|
||||
"title": "Inscription Automatique",
|
||||
"description": "Inscrit automatiquement les utilisateurs qui n'existent pas lors de la première connexion"
|
||||
},
|
||||
"oidcAdminEmailDomains": {
|
||||
"title": "Domaines Email Admin",
|
||||
"description": "Domaines email qui reçoivent automatiquement les privilèges d'administrateur"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -492,6 +535,14 @@
|
||||
"messages": {
|
||||
"noChanges": "Aucun changement à enregistrer",
|
||||
"updateSuccess": "Paramètres {group} mis à jour avec succès"
|
||||
},
|
||||
"tooltips": {
|
||||
"oidcScope": "Entrez une portée et appuyez sur Entrée pour l'ajouter",
|
||||
"oidcAdminEmailDomains": "Entrez un domaine et appuyez sur Entrée pour l'ajouter"
|
||||
},
|
||||
"redirectUri": {
|
||||
"placeholder": "https://monsite.com",
|
||||
"previewLabel": "URL complète qui sera enregistrée :"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
@@ -891,5 +942,14 @@
|
||||
},
|
||||
"expires": "Expire:",
|
||||
"expirationDate": "Date d'Expiration"
|
||||
},
|
||||
"auth": {
|
||||
"errors": {
|
||||
"account_inactive": "Compte inactif. Veuillez contacter l'administrateur.",
|
||||
"registration_disabled": "L'inscription via SSO est désactivée.",
|
||||
"token_expired": "Jeton expiré. Veuillez réessayer.",
|
||||
"config_error": "Erreur de configuration. Veuillez contacter le support.",
|
||||
"auth_failed": "Échec de l'authentification. Veuillez réessayer."
|
||||
}
|
||||
}
|
||||
}
|
@@ -207,7 +207,10 @@
|
||||
"signIn": "साइन इन करें",
|
||||
"signingIn": "साइन इन हो रहा है...",
|
||||
"forgotPassword": "पासवर्ड भूल गए?",
|
||||
"pageTitle": "लॉगिन"
|
||||
"pageTitle": "लॉगिन",
|
||||
"or": "ou",
|
||||
"continueWithSSO": "Continuar com SSO",
|
||||
"processing": "Processando autenticação..."
|
||||
},
|
||||
"logo": {
|
||||
"labels": {
|
||||
@@ -402,6 +405,10 @@
|
||||
"storage": {
|
||||
"title": "स्टोरेज",
|
||||
"description": "फाइल स्टोरेज कॉन्फ़िगरेशन"
|
||||
},
|
||||
"oidc": {
|
||||
"title": "OpenID Connect (SSO)",
|
||||
"description": "OpenID Connect के माध्यम से SSO प्रमाणीकरण कॉन्फ़िगरेशन"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
@@ -477,6 +484,42 @@
|
||||
"firstUserAccess": {
|
||||
"description": "नए उपयोगकर्ताओं की पहली पहुंच के लिए सेटिंग्स",
|
||||
"title": "पहली उपयोगकर्ता पहुंच"
|
||||
},
|
||||
"serverUrl": {
|
||||
"title": "सर्वर URL",
|
||||
"description": "Palmr सर्वर का आधार URL (उदाहरण: https://palmr.example.com)"
|
||||
},
|
||||
"oidcEnabled": {
|
||||
"title": "OIDC सक्षम",
|
||||
"description": "OpenID Connect के माध्यम से प्रमाणीकरण सक्षम या अक्षम करें"
|
||||
},
|
||||
"oidcIssuerUrl": {
|
||||
"title": "प्रदाता URL",
|
||||
"description": "OpenID Connect प्रदाता का URL (उदाहरण: https://auth.example.com/auth/realms/master)"
|
||||
},
|
||||
"oidcClientId": {
|
||||
"title": "क्लाइंट ID",
|
||||
"description": "प्रदाता में पंजीकृत OIDC क्लाइंट का पहचानकर्ता"
|
||||
},
|
||||
"oidcClientSecret": {
|
||||
"title": "क्लाइंट सीक्रेट",
|
||||
"description": "प्रमाणीकरण के लिए OIDC क्लाइंट का सीक्रेट"
|
||||
},
|
||||
"oidcRedirectUri": {
|
||||
"title": "रीडायरेक्ट URI",
|
||||
"description": "प्रमाणीकरण के बाद उपयोगकर्ता को रीडायरेक्ट करने के लिए URI। केवल आधार URL दर्ज करें (उदाहरण: https://mysite.com)। पथ /api/auth/oidc/callback स्वचालित रूप से जोड़ा जाएगा।"
|
||||
},
|
||||
"oidcScope": {
|
||||
"title": "स्कोप",
|
||||
"description": "अनुरोधित OIDC स्कोप (उदाहरण: openid profile email)"
|
||||
},
|
||||
"oidcAutoRegister": {
|
||||
"title": "स्वचालित पंजीकरण",
|
||||
"description": "पहले लॉगिन पर मौजूद नहीं होने वाले उपयोगकर्ताओं को स्वचालित रूप से पंजीकृत करें"
|
||||
},
|
||||
"oidcAdminEmailDomains": {
|
||||
"title": "एडमिन ईमेल डोमेन",
|
||||
"description": "जिन ईमेल डोमेन को स्वचालित रूप से व्यवस्थापक विशेषाधिकार प्राप्त होंगे"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -492,7 +535,15 @@
|
||||
},
|
||||
"title": "सेटिंग्स",
|
||||
"breadcrumb": "सेटिंग्स",
|
||||
"pageTitle": "सेटिंग्स"
|
||||
"pageTitle": "सेटिंग्स",
|
||||
"tooltips": {
|
||||
"oidcScope": "स्कोप जोड़ने के लिए एक स्कोप दर्ज करें और Enter दबाएं",
|
||||
"oidcAdminEmailDomains": "डोमेन जोड़ने के लिए एक डोमेन दर्ज करें और Enter दबाएं"
|
||||
},
|
||||
"redirectUri": {
|
||||
"placeholder": "https://mysite.com",
|
||||
"previewLabel": "पूर्ण URL जो सहेजा जाएगा:"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"errors": {
|
||||
@@ -891,5 +942,14 @@
|
||||
},
|
||||
"expires": "समाप्त होता है:",
|
||||
"expirationDate": "समाप्ति तिथि"
|
||||
},
|
||||
"auth": {
|
||||
"errors": {
|
||||
"account_inactive": "खाता निष्क्रिय है। कृपया व्यवस्थापक से संपर्क करें।",
|
||||
"registration_disabled": "SSO के माध्यम से पंजीकरण अक्षम है।",
|
||||
"token_expired": "टोकन समाप्त हो गया है। कृपया पुनः प्रयास करें।",
|
||||
"config_error": "कॉन्फ़िगरेशन त्रुटि। कृपया सहायता से संपर्क करें।",
|
||||
"auth_failed": "प्रमाणीकरण विफल। कृपया पुनः प्रयास करें।"
|
||||
}
|
||||
}
|
||||
}
|
@@ -207,7 +207,10 @@
|
||||
"signIn": "Accedi",
|
||||
"signingIn": "Accesso in corso...",
|
||||
"forgotPassword": "Parola d'accesso dimenticata?",
|
||||
"pageTitle": "Accesso"
|
||||
"pageTitle": "Accesso",
|
||||
"or": "ou",
|
||||
"continueWithSSO": "Continuar com SSO",
|
||||
"processing": "Processando autenticação..."
|
||||
},
|
||||
"logo": {
|
||||
"labels": {
|
||||
@@ -402,6 +405,10 @@
|
||||
"storage": {
|
||||
"title": "Archiviazione",
|
||||
"description": "Configurazione archiviazione file"
|
||||
},
|
||||
"oidc": {
|
||||
"title": "OpenID Connect (SSO)",
|
||||
"description": "Configurazione dell'autenticazione SSO tramite OpenID Connect"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
@@ -477,6 +484,42 @@
|
||||
"firstUserAccess": {
|
||||
"description": "Impostazioni per il primo accesso di nuovi utenti",
|
||||
"title": "Primo Accesso Utente"
|
||||
},
|
||||
"serverUrl": {
|
||||
"title": "URL del Server",
|
||||
"description": "URL base del server Palmr (es: https://palmr.esempio.com)"
|
||||
},
|
||||
"oidcEnabled": {
|
||||
"title": "OIDC Abilitato",
|
||||
"description": "Attiva o disattiva l'autenticazione tramite OpenID Connect"
|
||||
},
|
||||
"oidcIssuerUrl": {
|
||||
"title": "URL del Provider",
|
||||
"description": "URL del provider OpenID Connect (es: https://auth.esempio.com/auth/realms/master)"
|
||||
},
|
||||
"oidcClientId": {
|
||||
"title": "Client ID",
|
||||
"description": "Identificatore del client OIDC registrato nel provider"
|
||||
},
|
||||
"oidcClientSecret": {
|
||||
"title": "Client Secret",
|
||||
"description": "Segreto del client OIDC per l'autenticazione"
|
||||
},
|
||||
"oidcRedirectUri": {
|
||||
"title": "URI di Reindirizzamento",
|
||||
"description": "URI dove l'utente verrà reindirizzato dopo l'autenticazione. Inserisci solo l'URL base (es: https://miosito.com). Il percorso /api/auth/oidc/callback verrà aggiunto automaticamente."
|
||||
},
|
||||
"oidcScope": {
|
||||
"title": "Scope",
|
||||
"description": "Scope OIDC richiesti (es: openid profile email)"
|
||||
},
|
||||
"oidcAutoRegister": {
|
||||
"title": "Registrazione Automatica",
|
||||
"description": "Registra automaticamente gli utenti che non esistono al primo accesso"
|
||||
},
|
||||
"oidcAdminEmailDomains": {
|
||||
"title": "Domini Email Admin",
|
||||
"description": "Domini email che ricevono automaticamente i privilegi di amministratore"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -492,7 +535,15 @@
|
||||
},
|
||||
"title": "Impostazioni",
|
||||
"breadcrumb": "Impostazioni",
|
||||
"pageTitle": "Impostazioni"
|
||||
"pageTitle": "Impostazioni",
|
||||
"tooltips": {
|
||||
"oidcScope": "Inserisci uno scope e premi Invio per aggiungerlo",
|
||||
"oidcAdminEmailDomains": "Inserisci un dominio e premi Invio per aggiungerlo"
|
||||
},
|
||||
"redirectUri": {
|
||||
"placeholder": "https://miosito.com",
|
||||
"previewLabel": "URL completa che verrà salvata:"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"errors": {
|
||||
@@ -891,5 +942,14 @@
|
||||
},
|
||||
"expires": "Scade:",
|
||||
"expirationDate": "Data di Scadenza"
|
||||
},
|
||||
"auth": {
|
||||
"errors": {
|
||||
"account_inactive": "Account inattivo. Contatta l'amministratore.",
|
||||
"registration_disabled": "Registrazione tramite SSO disabilitata.",
|
||||
"token_expired": "Token scaduto. Riprova.",
|
||||
"config_error": "Errore di configurazione. Contatta il supporto.",
|
||||
"auth_failed": "Autenticazione fallita. Riprova."
|
||||
}
|
||||
}
|
||||
}
|
@@ -207,7 +207,10 @@
|
||||
"signIn": "サインイン",
|
||||
"signingIn": "サインイン中...",
|
||||
"forgotPassword": "パスワードをお忘れですか?",
|
||||
"pageTitle": "ログイン"
|
||||
"pageTitle": "ログイン",
|
||||
"or": "または",
|
||||
"continueWithSSO": "SSOで続行",
|
||||
"processing": "認証処理中..."
|
||||
},
|
||||
"logo": {
|
||||
"labels": {
|
||||
@@ -402,6 +405,10 @@
|
||||
"storage": {
|
||||
"title": "ストレージ",
|
||||
"description": "ファイルストレージの設定"
|
||||
},
|
||||
"oidc": {
|
||||
"title": "OpenID Connect (SSO)",
|
||||
"description": "OpenID Connectを使用したSSO認証の設定"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
@@ -477,6 +484,42 @@
|
||||
"firstUserAccess": {
|
||||
"description": "新規ユーザーの初回アクセス設定",
|
||||
"title": "初回ユーザーアクセス"
|
||||
},
|
||||
"serverUrl": {
|
||||
"title": "サーバーURL",
|
||||
"description": "PalmrサーバーのベースURL(例: https://palmr.example.com)"
|
||||
},
|
||||
"oidcEnabled": {
|
||||
"title": "OIDC有効",
|
||||
"description": "OpenID Connectによる認証を有効または無効にする"
|
||||
},
|
||||
"oidcIssuerUrl": {
|
||||
"title": "プロバイダーURL",
|
||||
"description": "OpenID ConnectプロバイダーのURL(例: https://auth.example.com/auth/realms/master)"
|
||||
},
|
||||
"oidcClientId": {
|
||||
"title": "クライアントID",
|
||||
"description": "プロバイダーに登録されたOIDCクライアントの識別子"
|
||||
},
|
||||
"oidcClientSecret": {
|
||||
"title": "クライアントシークレット",
|
||||
"description": "認証用のOIDCクライアントのシークレット"
|
||||
},
|
||||
"oidcRedirectUri": {
|
||||
"title": "リダイレクトURI",
|
||||
"description": "認証後にユーザーがリダイレクトされるURI。ベースURLのみを入力してください(例: https://mysite.com)。パス /api/auth/oidc/callback は自動的に追加されます。"
|
||||
},
|
||||
"oidcScope": {
|
||||
"title": "スコープ",
|
||||
"description": "要求するOIDCスコープ(例: openid profile email)"
|
||||
},
|
||||
"oidcAutoRegister": {
|
||||
"title": "自動登録",
|
||||
"description": "初回ログイン時に存在しないユーザーを自動的に登録"
|
||||
},
|
||||
"oidcAdminEmailDomains": {
|
||||
"title": "管理者メールドメイン",
|
||||
"description": "自動的に管理者権限が付与されるメールドメイン"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -492,7 +535,15 @@
|
||||
},
|
||||
"title": "設定",
|
||||
"breadcrumb": "設定",
|
||||
"pageTitle": "設定"
|
||||
"pageTitle": "設定",
|
||||
"tooltips": {
|
||||
"oidcScope": "スコープを入力してEnterキーを押して追加",
|
||||
"oidcAdminEmailDomains": "ドメインを入力してEnterキーを押して追加"
|
||||
},
|
||||
"redirectUri": {
|
||||
"placeholder": "https://mysite.com",
|
||||
"previewLabel": "保存される完全なURL:"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"errors": {
|
||||
@@ -891,5 +942,14 @@
|
||||
},
|
||||
"expires": "期限:",
|
||||
"expirationDate": "有効期限"
|
||||
},
|
||||
"auth": {
|
||||
"errors": {
|
||||
"account_inactive": "アカウントが無効です。管理者にお問い合わせください。",
|
||||
"registration_disabled": "SSOによる登録が無効になっています。",
|
||||
"token_expired": "トークンの有効期限が切れました。もう一度お試しください。",
|
||||
"config_error": "設定エラー。サポートにお問い合わせください。",
|
||||
"auth_failed": "認証に失敗しました。もう一度お試しください。"
|
||||
}
|
||||
}
|
||||
}
|
@@ -207,7 +207,10 @@
|
||||
"signIn": "로그인",
|
||||
"signingIn": "로그인 중...",
|
||||
"forgotPassword": "비밀번호를 잊으셨나요?",
|
||||
"pageTitle": "로그인"
|
||||
"pageTitle": "로그인",
|
||||
"or": "또는",
|
||||
"continueWithSSO": "SSO로 계속하기",
|
||||
"processing": "인증 처리 중..."
|
||||
},
|
||||
"logo": {
|
||||
"labels": {
|
||||
@@ -402,6 +405,10 @@
|
||||
"storage": {
|
||||
"title": "스토리지",
|
||||
"description": "파일 스토리지 구성"
|
||||
},
|
||||
"oidc": {
|
||||
"title": "OpenID Connect (SSO)",
|
||||
"description": "OpenID Connect를 사용한 SSO 인증 구성"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
@@ -477,6 +484,42 @@
|
||||
"firstUserAccess": {
|
||||
"description": "새 사용자의 첫 번째 액세스 설정",
|
||||
"title": "첫 번째 사용자 액세스"
|
||||
},
|
||||
"serverUrl": {
|
||||
"title": "서버 URL",
|
||||
"description": "Palmr 서버의 기본 URL (예: https://palmr.example.com)"
|
||||
},
|
||||
"oidcEnabled": {
|
||||
"title": "OIDC 활성화",
|
||||
"description": "OpenID Connect를 통한 인증을 활성화 또는 비활성화합니다"
|
||||
},
|
||||
"oidcIssuerUrl": {
|
||||
"title": "프로바이더 URL",
|
||||
"description": "OpenID Connect 프로바이더의 URL (예: https://auth.example.com/auth/realms/master)"
|
||||
},
|
||||
"oidcClientId": {
|
||||
"title": "클라이언트 ID",
|
||||
"description": "프로바이더에 등록된 OIDC 클라이언트 식별자"
|
||||
},
|
||||
"oidcClientSecret": {
|
||||
"title": "클라이언트 시크릿",
|
||||
"description": "인증을 위한 OIDC 클라이언트의 시크릿"
|
||||
},
|
||||
"oidcRedirectUri": {
|
||||
"title": "리디렉션 URI",
|
||||
"description": "인증 후 사용자가 리디렉션될 URI. 기본 URL만 입력하세요 (예: https://mysite.com). 경로 /api/auth/oidc/callback는 자동으로 추가됩니다."
|
||||
},
|
||||
"oidcScope": {
|
||||
"title": "스코프",
|
||||
"description": "요청하는 OIDC 스코프 (예: openid profile email)"
|
||||
},
|
||||
"oidcAutoRegister": {
|
||||
"title": "자동 등록",
|
||||
"description": "첫 로그인 시 존재하지 않는 사용자를 자동으로 등록합니다"
|
||||
},
|
||||
"oidcAdminEmailDomains": {
|
||||
"title": "관리자 이메일 도메인",
|
||||
"description": "자동으로 관리자 권한이 부여되는 이메일 도메인"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -492,7 +535,15 @@
|
||||
},
|
||||
"title": "설정",
|
||||
"breadcrumb": "설정",
|
||||
"pageTitle": "설정"
|
||||
"pageTitle": "설정",
|
||||
"tooltips": {
|
||||
"oidcScope": "스코프를 입력하고 Enter 키를 눌러 추가",
|
||||
"oidcAdminEmailDomains": "도메인을 입력하고 Enter 키를 눌러 추가"
|
||||
},
|
||||
"redirectUri": {
|
||||
"placeholder": "https://mysite.com",
|
||||
"previewLabel": "저장될 전체 URL:"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"errors": {
|
||||
@@ -891,5 +942,14 @@
|
||||
},
|
||||
"expires": "만료:",
|
||||
"expirationDate": "만료 날짜"
|
||||
},
|
||||
"auth": {
|
||||
"errors": {
|
||||
"account_inactive": "계정이 비활성화되었습니다. 관리자에게 문의하세요.",
|
||||
"registration_disabled": "SSO를 통한 등록이 비활성화되었습니다.",
|
||||
"token_expired": "토큰이 만료되었습니다. 다시 시도하세요.",
|
||||
"config_error": "구성 오류. 지원팀에 문의하세요.",
|
||||
"auth_failed": "인증에 실패했습니다. 다시 시도하세요."
|
||||
}
|
||||
}
|
||||
}
|
@@ -207,7 +207,10 @@
|
||||
"signIn": "Inloggen",
|
||||
"signingIn": "Inloggen...",
|
||||
"forgotPassword": "Wachtwoord vergeten?",
|
||||
"pageTitle": "Aanmelden"
|
||||
"pageTitle": "Aanmelden",
|
||||
"or": "ou",
|
||||
"continueWithSSO": "Continuar com SSO",
|
||||
"processing": "Processando autenticação..."
|
||||
},
|
||||
"logo": {
|
||||
"labels": {
|
||||
@@ -402,6 +405,10 @@
|
||||
"storage": {
|
||||
"title": "Opslag",
|
||||
"description": "Bestandsopslag configuratie"
|
||||
},
|
||||
"oidc": {
|
||||
"title": "OpenID Connect (SSO)",
|
||||
"description": "Configuração de autenticação SSO via OpenID Connect"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
@@ -477,6 +484,42 @@
|
||||
"firstUserAccess": {
|
||||
"description": "Instellingen voor eerste toegang van nieuwe gebruikers",
|
||||
"title": "Eerste Gebruikerstoegang"
|
||||
},
|
||||
"serverUrl": {
|
||||
"title": "Server URL",
|
||||
"description": "Basis URL van de Palmr server (bijv: https://palmr.voorbeeld.com)"
|
||||
},
|
||||
"oidcEnabled": {
|
||||
"title": "OIDC Ingeschakeld",
|
||||
"description": "Schakel authenticatie via OpenID Connect in of uit"
|
||||
},
|
||||
"oidcIssuerUrl": {
|
||||
"title": "Provider URL",
|
||||
"description": "URL van de OpenID Connect provider (bijv: https://auth.voorbeeld.com/auth/realms/master)"
|
||||
},
|
||||
"oidcClientId": {
|
||||
"title": "Client ID",
|
||||
"description": "OIDC client ID geregistreerd bij de provider"
|
||||
},
|
||||
"oidcClientSecret": {
|
||||
"title": "Client Secret",
|
||||
"description": "OIDC client secret voor authenticatie"
|
||||
},
|
||||
"oidcRedirectUri": {
|
||||
"title": "Redirect URI",
|
||||
"description": "URI waar de gebruiker naartoe wordt gestuurd na authenticatie. Voer alleen de basis URL in (bijv: https://mijnsite.com). Het pad /api/auth/oidc/callback wordt automatisch toegevoegd."
|
||||
},
|
||||
"oidcScope": {
|
||||
"title": "Scopes",
|
||||
"description": "Gevraagde OIDC scopes (bijv: openid profile email)"
|
||||
},
|
||||
"oidcAutoRegister": {
|
||||
"title": "Automatische Registratie",
|
||||
"description": "Registreer automatisch gebruikers die niet bestaan bij eerste login"
|
||||
},
|
||||
"oidcAdminEmailDomains": {
|
||||
"title": "Admin E-mail Domeinen",
|
||||
"description": "E-mail domeinen die automatisch admin rechten krijgen"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -492,7 +535,15 @@
|
||||
},
|
||||
"title": "Instellingen",
|
||||
"breadcrumb": "Instellingen",
|
||||
"pageTitle": "Instellingen"
|
||||
"pageTitle": "Instellingen",
|
||||
"tooltips": {
|
||||
"oidcScope": "Voer een scope in en druk op Enter om toe te voegen",
|
||||
"oidcAdminEmailDomains": "Voer een domein in en druk op Enter om toe te voegen"
|
||||
},
|
||||
"redirectUri": {
|
||||
"placeholder": "https://mijnsite.com",
|
||||
"previewLabel": "Volledige URL die wordt opgeslagen:"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"errors": {
|
||||
@@ -891,5 +942,14 @@
|
||||
},
|
||||
"expires": "Verloopt:",
|
||||
"expirationDate": "Vervaldatum"
|
||||
},
|
||||
"auth": {
|
||||
"errors": {
|
||||
"account_inactive": "Account inactief. Neem contact op met de beheerder.",
|
||||
"registration_disabled": "Registratie via SSO is uitgeschakeld.",
|
||||
"token_expired": "Token verlopen. Probeer het opnieuw.",
|
||||
"config_error": "Configuratiefout. Neem contact op met support.",
|
||||
"auth_failed": "Authenticatie mislukt. Probeer het opnieuw."
|
||||
}
|
||||
}
|
||||
}
|
@@ -229,7 +229,10 @@
|
||||
"signIn": "Entrar",
|
||||
"signingIn": "Entrando...",
|
||||
"forgotPassword": "Esqueceu a senha?",
|
||||
"pageTitle": "Entrar"
|
||||
"pageTitle": "Entrar",
|
||||
"or": "ou",
|
||||
"continueWithSSO": "Continuar com SSO",
|
||||
"processing": "Processando autenticação..."
|
||||
},
|
||||
"logo": {
|
||||
"labels": {
|
||||
@@ -424,12 +427,24 @@
|
||||
"storage": {
|
||||
"title": "Armazenamento",
|
||||
"description": "Configuração de armazenamento de arquivos"
|
||||
},
|
||||
"oidc": {
|
||||
"title": "OpenID Connect (SSO)",
|
||||
"description": "Configuração de autenticação SSO via OpenID Connect"
|
||||
}
|
||||
},
|
||||
"tooltips": {
|
||||
"oidcScope": "Digite um escopo e pressione Enter para adicionar",
|
||||
"oidcAdminEmailDomains": "Digite um domínio e pressione Enter para adicionar"
|
||||
},
|
||||
"redirectUri": {
|
||||
"placeholder": "https://meusite.com",
|
||||
"previewLabel": "URL completa que será salva:"
|
||||
},
|
||||
"fields": {
|
||||
"noDescription": "Sem descrição disponível",
|
||||
"firstUserAccess": {
|
||||
"title": "Primeiro acesso do usuário",
|
||||
"title": "Primeiro Acesso do Usuário",
|
||||
"description": "Configurações para o primeiro acesso de novos usuários"
|
||||
},
|
||||
"appLogo": {
|
||||
@@ -499,6 +514,42 @@
|
||||
"maxTotalStoragePerUser": {
|
||||
"title": "Armazenamento Máximo por Usuário",
|
||||
"description": "Limite total de armazenamento por usuário"
|
||||
},
|
||||
"serverUrl": {
|
||||
"title": "URL do Servidor",
|
||||
"description": "URL base do servidor Palmr (ex: https://palmr.exemplo.com)"
|
||||
},
|
||||
"oidcEnabled": {
|
||||
"title": "OIDC Habilitado",
|
||||
"description": "Ativa ou desativa a autenticação via OpenID Connect"
|
||||
},
|
||||
"oidcIssuerUrl": {
|
||||
"title": "URL do Provedor",
|
||||
"description": "URL do provedor OpenID Connect (ex: https://auth.exemplo.com/auth/realms/master)"
|
||||
},
|
||||
"oidcClientId": {
|
||||
"title": "Client ID",
|
||||
"description": "Identificador do cliente OIDC registrado no provedor"
|
||||
},
|
||||
"oidcClientSecret": {
|
||||
"title": "Client Secret",
|
||||
"description": "Segredo do cliente OIDC para autenticação"
|
||||
},
|
||||
"oidcRedirectUri": {
|
||||
"title": "URI de Redirecionamento",
|
||||
"description": "URI para onde o usuário será redirecionado após autenticação. Digite apenas a URL base (ex: https://meusite.com). O path /api/auth/oidc/callback será adicionado automaticamente."
|
||||
},
|
||||
"oidcScope": {
|
||||
"title": "Escopos",
|
||||
"description": "Escopos OIDC solicitados (ex: openid profile email)"
|
||||
},
|
||||
"oidcAutoRegister": {
|
||||
"title": "Registro Automático",
|
||||
"description": "Registra automaticamente usuários que não existem no primeiro login"
|
||||
},
|
||||
"oidcAdminEmailDomains": {
|
||||
"title": "Domínios de E-mail Admin",
|
||||
"description": "Domínios de e-mail que recebem privilégios de administrador automaticamente"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -891,5 +942,14 @@
|
||||
},
|
||||
"expires": "Expira:",
|
||||
"expirationDate": "Data de Expiração"
|
||||
},
|
||||
"auth": {
|
||||
"errors": {
|
||||
"account_inactive": "Conta inativa. Entre em contato com o administrador.",
|
||||
"registration_disabled": "Registro via SSO está desabilitado.",
|
||||
"token_expired": "Token expirado. Tente novamente.",
|
||||
"config_error": "Erro de configuração. Contate o suporte.",
|
||||
"auth_failed": "Falha na autenticação. Tente novamente."
|
||||
}
|
||||
}
|
||||
}
|
@@ -207,7 +207,10 @@
|
||||
"signIn": "Войти",
|
||||
"signingIn": "Вход...",
|
||||
"forgotPassword": "Забыли пароль?",
|
||||
"pageTitle": "Вход"
|
||||
"pageTitle": "Вход",
|
||||
"or": "или",
|
||||
"continueWithSSO": "Продолжить с SSO",
|
||||
"processing": "Обработка аутентификации..."
|
||||
},
|
||||
"logo": {
|
||||
"labels": {
|
||||
@@ -402,6 +405,10 @@
|
||||
"storage": {
|
||||
"title": "Хранилище",
|
||||
"description": "Настройки хранения файлов"
|
||||
},
|
||||
"oidc": {
|
||||
"title": "OpenID Connect (SSO)",
|
||||
"description": "Настройка аутентификации SSO через OpenID Connect"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
@@ -477,6 +484,42 @@
|
||||
"firstUserAccess": {
|
||||
"title": "Первый доступ пользователя",
|
||||
"description": "Время первого доступа пользователя"
|
||||
},
|
||||
"serverUrl": {
|
||||
"title": "URL do Servidor",
|
||||
"description": "URL base do servidor Palmr (ex: https://palmr.exemplo.com)"
|
||||
},
|
||||
"oidcEnabled": {
|
||||
"title": "OIDC Включен",
|
||||
"description": "Включить или отключить аутентификацию через OpenID Connect"
|
||||
},
|
||||
"oidcIssuerUrl": {
|
||||
"title": "URL Провайдера",
|
||||
"description": "URL провайдера OpenID Connect (например: https://auth.example.com/auth/realms/master)"
|
||||
},
|
||||
"oidcClientId": {
|
||||
"title": "Client ID",
|
||||
"description": "Идентификатор клиента OIDC, зарегистрированный у провайдера"
|
||||
},
|
||||
"oidcClientSecret": {
|
||||
"title": "Client Secret",
|
||||
"description": "Секрет клиента OIDC для аутентификации"
|
||||
},
|
||||
"oidcRedirectUri": {
|
||||
"title": "URI Перенаправления",
|
||||
"description": "URI, куда пользователь будет перенаправлен после аутентификации. Введите только базовый URL (например: https://mysite.com). Путь /api/auth/oidc/callback будет добавлен автоматически."
|
||||
},
|
||||
"oidcScope": {
|
||||
"title": "Области",
|
||||
"description": "Запрашиваемые области OIDC (например: openid profile email)"
|
||||
},
|
||||
"oidcAutoRegister": {
|
||||
"title": "Автоматическая Регистрация",
|
||||
"description": "Автоматически регистрировать пользователей, которых нет при первом входе"
|
||||
},
|
||||
"oidcAdminEmailDomains": {
|
||||
"title": "Домены Email Администратора",
|
||||
"description": "Домены электронной почты, которые автоматически получают права администратора"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -492,7 +535,15 @@
|
||||
},
|
||||
"title": "Настройки",
|
||||
"breadcrumb": "Настройки",
|
||||
"pageTitle": "Настройки"
|
||||
"pageTitle": "Настройки",
|
||||
"tooltips": {
|
||||
"oidcScope": "Digite um escopo e pressione Enter para adicionar",
|
||||
"oidcAdminEmailDomains": "Digite um domínio e pressione Enter para adicionar"
|
||||
},
|
||||
"redirectUri": {
|
||||
"placeholder": "https://meusite.com",
|
||||
"previewLabel": "URL completa que será salva:"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"errors": {
|
||||
@@ -891,5 +942,14 @@
|
||||
},
|
||||
"expires": "Истекает:",
|
||||
"expirationDate": "Дата истечения"
|
||||
},
|
||||
"auth": {
|
||||
"errors": {
|
||||
"account_inactive": "Conta inativa. Entre em contato com o administrador.",
|
||||
"registration_disabled": "Registro via SSO está desabilitado.",
|
||||
"token_expired": "Token expirado. Tente novamente.",
|
||||
"config_error": "Erro de configuração. Contate o suporte.",
|
||||
"auth_failed": "Falha na autenticação. Tente novamente."
|
||||
}
|
||||
}
|
||||
}
|
@@ -207,7 +207,10 @@
|
||||
"signIn": "Oturum Aç",
|
||||
"signingIn": "Oturum açılıyor...",
|
||||
"forgotPassword": "Şifrenizi mi unuttunuz?",
|
||||
"pageTitle": "Giriş"
|
||||
"pageTitle": "Giriş",
|
||||
"or": "ou",
|
||||
"continueWithSSO": "Continuar com SSO",
|
||||
"processing": "Processando autenticação..."
|
||||
},
|
||||
"logo": {
|
||||
"labels": {
|
||||
@@ -402,6 +405,10 @@
|
||||
"storage": {
|
||||
"title": "Depolama",
|
||||
"description": "Dosya depolama yapılandırması"
|
||||
},
|
||||
"oidc": {
|
||||
"title": "OpenID Connect (SSO)",
|
||||
"description": "OpenID Connect üzerinden SSO kimlik doğrulama yapılandırması"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
@@ -477,6 +484,42 @@
|
||||
"firstUserAccess": {
|
||||
"title": "İlk Kullanıcı Erişimi",
|
||||
"description": "İlk kullanıcının erişim izni"
|
||||
},
|
||||
"serverUrl": {
|
||||
"title": "Sunucu URL'si",
|
||||
"description": "Palmr sunucu temel URL'si (örn: https://palmr.ornek.com)"
|
||||
},
|
||||
"oidcEnabled": {
|
||||
"title": "OIDC Etkin",
|
||||
"description": "OpenID Connect üzerinden kimlik doğrulamayı etkinleştir veya devre dışı bırak"
|
||||
},
|
||||
"oidcIssuerUrl": {
|
||||
"title": "Sağlayıcı URL'si",
|
||||
"description": "OpenID Connect sağlayıcı URL'si (örn: https://auth.ornek.com/auth/realms/master)"
|
||||
},
|
||||
"oidcClientId": {
|
||||
"title": "Client ID",
|
||||
"description": "Sağlayıcıda kayıtlı OIDC istemci tanımlayıcısı"
|
||||
},
|
||||
"oidcClientSecret": {
|
||||
"title": "Client Secret",
|
||||
"description": "Kimlik doğrulama için OIDC istemci sırrı"
|
||||
},
|
||||
"oidcRedirectUri": {
|
||||
"title": "Yönlendirme URI'si",
|
||||
"description": "Kimlik doğrulama sonrası kullanıcının yönlendirileceği URI. Sadece temel URL'yi girin (örn: https://sitem.com). /api/auth/oidc/callback yolu otomatik olarak eklenecektir."
|
||||
},
|
||||
"oidcScope": {
|
||||
"title": "Kapsamlar",
|
||||
"description": "İstenen OIDC kapsamları (örn: openid profile email)"
|
||||
},
|
||||
"oidcAutoRegister": {
|
||||
"title": "Otomatik Kayıt",
|
||||
"description": "İlk girişte olmayan kullanıcıları otomatik olarak kaydet"
|
||||
},
|
||||
"oidcAdminEmailDomains": {
|
||||
"title": "Yönetici E-posta Alanları",
|
||||
"description": "Otomatik olarak yönetici ayrıcalıkları alan e-posta alanları"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -492,7 +535,15 @@
|
||||
},
|
||||
"title": "Ayarlar",
|
||||
"breadcrumb": "Ayarlar",
|
||||
"pageTitle": "Ayarlar"
|
||||
"pageTitle": "Ayarlar",
|
||||
"tooltips": {
|
||||
"oidcScope": "Bir kapsam girin ve eklemek için Enter'a basın",
|
||||
"oidcAdminEmailDomains": "Bir alan girin ve eklemek için Enter'a basın"
|
||||
},
|
||||
"redirectUri": {
|
||||
"placeholder": "https://sitem.com",
|
||||
"previewLabel": "Kaydedilecek tam URL:"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"errors": {
|
||||
@@ -891,5 +942,14 @@
|
||||
},
|
||||
"expires": "Sona erer:",
|
||||
"expirationDate": "Son Kullanma Tarihi"
|
||||
},
|
||||
"auth": {
|
||||
"errors": {
|
||||
"account_inactive": "Hesap devre dışı. Lütfen yönetici ile iletişime geçin.",
|
||||
"registration_disabled": "SSO ile kayıt devre dışı.",
|
||||
"token_expired": "Token süresi doldu. Lütfen tekrar deneyin.",
|
||||
"config_error": "Yapılandırma hatası. Lütfen destek ile iletişime geçin.",
|
||||
"auth_failed": "Kimlik doğrulama başarısız. Lütfen tekrar deneyin."
|
||||
}
|
||||
}
|
||||
}
|
@@ -207,7 +207,10 @@
|
||||
"signIn": "登录",
|
||||
"signingIn": "正在登录...",
|
||||
"forgotPassword": "忘记密码?",
|
||||
"pageTitle": "登录"
|
||||
"pageTitle": "登录",
|
||||
"or": "或",
|
||||
"continueWithSSO": "使用SSO继续",
|
||||
"processing": "正在处理认证..."
|
||||
},
|
||||
"logo": {
|
||||
"labels": {
|
||||
@@ -402,6 +405,10 @@
|
||||
"storage": {
|
||||
"title": "存储",
|
||||
"description": "文件存储配置"
|
||||
},
|
||||
"oidc": {
|
||||
"title": "OpenID Connect (SSO)",
|
||||
"description": "通过OpenID Connect配置SSO认证"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
@@ -477,6 +484,42 @@
|
||||
"firstUserAccess": {
|
||||
"description": "新用户首次访问的设置",
|
||||
"title": "首次用户访问"
|
||||
},
|
||||
"serverUrl": {
|
||||
"title": "URL do Servidor",
|
||||
"description": "URL base do servidor Palmr (ex: https://palmr.exemplo.com)"
|
||||
},
|
||||
"oidcEnabled": {
|
||||
"title": "启用OIDC",
|
||||
"description": "启用或禁用OpenID Connect认证"
|
||||
},
|
||||
"oidcIssuerUrl": {
|
||||
"title": "提供商URL",
|
||||
"description": "OpenID Connect提供商URL(例如:https://auth.example.com/auth/realms/master)"
|
||||
},
|
||||
"oidcClientId": {
|
||||
"title": "客户端ID",
|
||||
"description": "在提供商处注册的OIDC客户端标识符"
|
||||
},
|
||||
"oidcClientSecret": {
|
||||
"title": "客户端密钥",
|
||||
"description": "用于认证的OIDC客户端密钥"
|
||||
},
|
||||
"oidcRedirectUri": {
|
||||
"title": "重定向URI",
|
||||
"description": "认证后用户将被重定向到的URI。仅输入基本URL(例如:https://mysite.com)。路径/api/auth/oidc/callback将自动添加。"
|
||||
},
|
||||
"oidcScope": {
|
||||
"title": "范围",
|
||||
"description": "请求的OIDC范围(例如:openid profile email)"
|
||||
},
|
||||
"oidcAutoRegister": {
|
||||
"title": "自动注册",
|
||||
"description": "首次登录时自动注册不存在的用户"
|
||||
},
|
||||
"oidcAdminEmailDomains": {
|
||||
"title": "管理员电子邮件域名",
|
||||
"description": "自动获得管理员权限的电子邮件域名"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -492,7 +535,15 @@
|
||||
},
|
||||
"title": "设置",
|
||||
"breadcrumb": "设置",
|
||||
"pageTitle": "设置"
|
||||
"pageTitle": "设置",
|
||||
"tooltips": {
|
||||
"oidcScope": "Digite um escopo e pressione Enter para adicionar",
|
||||
"oidcAdminEmailDomains": "Digite um domínio e pressione Enter para adicionar"
|
||||
},
|
||||
"redirectUri": {
|
||||
"placeholder": "https://meusite.com",
|
||||
"previewLabel": "URL completa que será salva:"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"errors": {
|
||||
@@ -891,5 +942,14 @@
|
||||
},
|
||||
"expires": "过期:",
|
||||
"expirationDate": "过期日期"
|
||||
},
|
||||
"auth": {
|
||||
"errors": {
|
||||
"account_inactive": "Conta inativa. Entre em contato com o administrador.",
|
||||
"registration_disabled": "Registro via SSO está desabilitado.",
|
||||
"token_expired": "Token expirado. Tente novamente.",
|
||||
"config_error": "Erro de configuração. Contate o suporte.",
|
||||
"auth_failed": "Falha na autenticação. Tente novamente."
|
||||
}
|
||||
}
|
||||
}
|
@@ -43,6 +43,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.6.3",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.487.0",
|
||||
"nanoid": "^5.1.5",
|
||||
@@ -65,6 +66,7 @@
|
||||
"@eslint/js": "9.23.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "4.4.1",
|
||||
"@tailwindcss/postcss": "4.1.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "22.14.0",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.1",
|
||||
|
17
apps/web/pnpm-lock.yaml
generated
17
apps/web/pnpm-lock.yaml
generated
@@ -68,6 +68,9 @@ importers:
|
||||
framer-motion:
|
||||
specifier: ^12.6.3
|
||||
version: 12.7.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
js-cookie:
|
||||
specifier: ^3.0.5
|
||||
version: 3.0.5
|
||||
jszip:
|
||||
specifier: ^3.10.1
|
||||
version: 3.10.1
|
||||
@@ -129,6 +132,9 @@ importers:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: 4.1.2
|
||||
version: 4.1.2
|
||||
'@types/js-cookie':
|
||||
specifier: ^3.0.6
|
||||
version: 3.0.6
|
||||
'@types/node':
|
||||
specifier: 22.14.0
|
||||
version: 22.14.0
|
||||
@@ -1125,6 +1131,9 @@ packages:
|
||||
'@types/estree@1.0.7':
|
||||
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
|
||||
|
||||
'@types/js-cookie@3.0.6':
|
||||
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
@@ -1997,6 +2006,10 @@ packages:
|
||||
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
|
||||
hasBin: true
|
||||
|
||||
js-cookie@3.0.5:
|
||||
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -3628,6 +3641,8 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.7': {}
|
||||
|
||||
'@types/js-cookie@3.0.6': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/json5@0.0.29': {}
|
||||
@@ -4668,6 +4683,8 @@ snapshots:
|
||||
|
||||
jiti@2.4.2: {}
|
||||
|
||||
js-cookie@3.0.5: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.1.0:
|
||||
|
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react"; // Add this import
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { IconHeart, IconMenu2 } from "@tabler/icons-react";
|
||||
|
||||
|
@@ -4,47 +4,32 @@ import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { create } from "zustand";
|
||||
|
||||
import { getAllConfigs } from "@/http/endpoints";
|
||||
|
||||
interface Config {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
import { useSecureConfigValue } from "@/hooks/use-secure-configs";
|
||||
|
||||
interface HomeStore {
|
||||
isLoading: boolean;
|
||||
checkHomePageAccess: () => Promise<boolean>;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
const useHomeStore = create<HomeStore>((set) => ({
|
||||
isLoading: true,
|
||||
checkHomePageAccess: async () => {
|
||||
try {
|
||||
const response = await getAllConfigs();
|
||||
const showHomePage =
|
||||
response.data.configs.find((config: Config) => config.key === "showHomePage")?.value === "true";
|
||||
|
||||
set({ isLoading: false });
|
||||
return showHomePage;
|
||||
} catch (error) {
|
||||
console.error("Failed to check homepage access:", error);
|
||||
set({ isLoading: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
setIsLoading: (loading: boolean) => set({ isLoading: loading }),
|
||||
}));
|
||||
|
||||
export function useHome() {
|
||||
const router = useRouter();
|
||||
const { isLoading, checkHomePageAccess } = useHomeStore();
|
||||
const { isLoading, setIsLoading } = useHomeStore();
|
||||
const { value: showHomePage, isLoading: configLoading } = useSecureConfigValue("showHomePage");
|
||||
|
||||
useEffect(() => {
|
||||
checkHomePageAccess().then((hasAccess) => {
|
||||
if (!hasAccess) {
|
||||
if (!configLoading) {
|
||||
setIsLoading(false);
|
||||
|
||||
if (showHomePage !== "true") {
|
||||
router.push("/login");
|
||||
}
|
||||
});
|
||||
}, [router, checkHomePageAccess]);
|
||||
}
|
||||
}, [router, showHomePage, configLoading, setIsLoading]);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
|
@@ -45,29 +45,26 @@ export function usePublicShare() {
|
||||
} else {
|
||||
toast.error(t("share.errors.loadFailed"));
|
||||
}
|
||||
console.error(error);
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = async () => {
|
||||
await loadShare(password);
|
||||
};
|
||||
|
||||
const handleDownload = async (objectName: string, fileName: string) => {
|
||||
const handleDownload = async (file: { id: string; name: string }) => {
|
||||
try {
|
||||
const encodedObjectName = encodeURIComponent(objectName);
|
||||
const response = await getDownloadUrl(encodedObjectName);
|
||||
const response = await getDownloadUrl(file.id);
|
||||
const downloadUrl = response.data.url;
|
||||
console.log(fileName)
|
||||
const fileName = downloadUrl.split("/").pop() || file.name;
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
toast.success(t("files.downloadStart"));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.success(t("files.downloadError"));
|
||||
toast.error(t("share.errors.downloadFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
|
@@ -4,7 +4,8 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getAllConfigs, listUserShares, notifyRecipients } from "@/http/endpoints";
|
||||
import { useSecureConfigValue } from "@/hooks/use-secure-configs";
|
||||
import { listUserShares, notifyRecipients } from "@/http/endpoints";
|
||||
import { ListUserShares200SharesItem } from "@/http/models/listUserShares200SharesItem";
|
||||
|
||||
export function useShares() {
|
||||
@@ -14,7 +15,8 @@ export function useShares() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [shareToViewDetails, setShareToViewDetails] = useState<ListUserShares200SharesItem | null>(null);
|
||||
const [shareToGenerateLink, setShareToGenerateLink] = useState<ListUserShares200SharesItem | null>(null);
|
||||
const [smtpEnabled, setSmtpEnabled] = useState("false");
|
||||
|
||||
const { value: smtpEnabled } = useSecureConfigValue("smtpEnabled");
|
||||
|
||||
const loadShares = async () => {
|
||||
try {
|
||||
@@ -27,26 +29,13 @@ export function useShares() {
|
||||
setShares(sortedShares);
|
||||
} catch (error) {
|
||||
toast.error(t("shares.errors.loadFailed"));
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const response = await getAllConfigs();
|
||||
const smtpConfig = response.data.configs.find((config: any) => config.key === "smtpEnabled");
|
||||
|
||||
setSmtpEnabled(smtpConfig?.value || "false");
|
||||
} catch (error) {
|
||||
console.error(t("shares.errors.smtpConfigFailed"), error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadShares();
|
||||
loadConfigs();
|
||||
}, []);
|
||||
|
||||
const filteredShares = shares.filter(
|
||||
@@ -71,7 +60,6 @@ export function useShares() {
|
||||
await notifyRecipients(share.id, { shareLink: link });
|
||||
toast.success(t("shares.messages.recipientsNotified"));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t("shares.errors.notifyFailed"));
|
||||
}
|
||||
};
|
||||
@@ -83,7 +71,7 @@ export function useShares() {
|
||||
shareToViewDetails,
|
||||
shareToGenerateLink,
|
||||
filteredShares,
|
||||
smtpEnabled,
|
||||
smtpEnabled: smtpEnabled || "false",
|
||||
setSearchQuery,
|
||||
setShareToViewDetails,
|
||||
setShareToGenerateLink,
|
||||
|
46
apps/web/src/app/api/(proxy)/auth/oidc/authorize/route.ts
Normal file
46
apps/web/src/app/api/(proxy)/auth/oidc/authorize/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
const forwardedHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
const protocol = req.nextUrl.protocol.replace(":", "");
|
||||
const host = req.headers.get("host") || req.nextUrl.host;
|
||||
|
||||
forwardedHeaders["x-forwarded-proto"] = protocol;
|
||||
forwardedHeaders["x-forwarded-host"] = host;
|
||||
|
||||
if (req.headers.get("referer")) {
|
||||
forwardedHeaders["referer"] = req.headers.get("referer")!;
|
||||
}
|
||||
if (req.headers.get("origin")) {
|
||||
forwardedHeaders["origin"] = req.headers.get("origin")!;
|
||||
}
|
||||
|
||||
const apiRes = await fetch(`${process.env.API_BASE_URL}/auth/oidc/authorize${queryString ? `?${queryString}` : ""}`, {
|
||||
method: "GET",
|
||||
headers: forwardedHeaders,
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
if (apiRes.status >= 300 && apiRes.status < 400) {
|
||||
const location = apiRes.headers.get("Location");
|
||||
if (location) {
|
||||
return NextResponse.redirect(location);
|
||||
}
|
||||
}
|
||||
|
||||
const resBody = await apiRes.text();
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
51
apps/web/src/app/api/(proxy)/auth/oidc/callback/route.ts
Normal file
51
apps/web/src/app/api/(proxy)/auth/oidc/callback/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
const forwardedHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
const protocol = req.nextUrl.protocol.replace(":", "");
|
||||
const host = req.headers.get("host") || req.nextUrl.host;
|
||||
|
||||
forwardedHeaders["x-forwarded-proto"] = protocol;
|
||||
forwardedHeaders["x-forwarded-host"] = host;
|
||||
|
||||
if (req.headers.get("referer")) {
|
||||
forwardedHeaders["referer"] = req.headers.get("referer")!;
|
||||
}
|
||||
if (req.headers.get("origin")) {
|
||||
forwardedHeaders["origin"] = req.headers.get("origin")!;
|
||||
}
|
||||
|
||||
const apiRes = await fetch(`${process.env.API_BASE_URL}/auth/oidc/callback${queryString ? `?${queryString}` : ""}`, {
|
||||
method: "GET",
|
||||
headers: forwardedHeaders,
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
if (apiRes.status >= 300 && apiRes.status < 400) {
|
||||
const location = apiRes.headers.get("Location");
|
||||
if (location) {
|
||||
return NextResponse.redirect(location);
|
||||
}
|
||||
}
|
||||
|
||||
const resBody = await apiRes.text();
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const setCookie = apiRes.headers.getSetCookie?.() || [];
|
||||
if (setCookie.length > 0) {
|
||||
res.headers.set("Set-Cookie", setCookie.join(","));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
36
apps/web/src/app/api/(proxy)/auth/oidc/config/route.ts
Normal file
36
apps/web/src/app/api/(proxy)/auth/oidc/config/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const forwardedHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
const protocol = req.nextUrl.protocol.replace(":", "");
|
||||
const host = req.headers.get("host") || req.nextUrl.host;
|
||||
|
||||
forwardedHeaders["x-forwarded-proto"] = protocol;
|
||||
forwardedHeaders["x-forwarded-host"] = host;
|
||||
|
||||
if (req.headers.get("referer")) {
|
||||
forwardedHeaders["referer"] = req.headers.get("referer")!;
|
||||
}
|
||||
if (req.headers.get("origin")) {
|
||||
forwardedHeaders["origin"] = req.headers.get("origin")!;
|
||||
}
|
||||
|
||||
const apiRes = await fetch(`${process.env.API_BASE_URL}/auth/oidc/config`, {
|
||||
method: "GET",
|
||||
headers: forwardedHeaders,
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
const resBody = await apiRes.text();
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
@@ -1,28 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
|
||||
const apiRes = await fetch(`${process.env.API_BASE_URL}/app/configs`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
const resBody = await apiRes.text();
|
||||
const res = new NextResponse(resBody, {
|
||||
status: apiRes.status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const setCookie = apiRes.headers.getSetCookie?.() || [];
|
||||
if (setCookie.length > 0) {
|
||||
res.headers.set("Set-Cookie", setCookie.join(","));
|
||||
}
|
||||
|
||||
return res;
|
||||
return new NextResponse(
|
||||
JSON.stringify({
|
||||
error: "This endpoint has been deprecated for security reasons. Use secure server actions instead.",
|
||||
message: "Please use getSecureConfigs() or getAdminConfigs() server actions",
|
||||
}),
|
||||
{
|
||||
status: 410,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: { objectName: string } }) {
|
||||
// Await params before destructuring
|
||||
const { objectName } = await params;
|
||||
const cookieHeader = req.headers.get("cookie");
|
||||
|
||||
|
38
apps/web/src/app/auth/callback/page.tsx
Normal file
38
apps/web/src/app/auth/callback/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get("token");
|
||||
|
||||
if (token) {
|
||||
Cookies.set("token", token, {
|
||||
path: "/",
|
||||
secure: false,
|
||||
sameSite: "strict",
|
||||
httpOnly: false,
|
||||
});
|
||||
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
router.replace("/dashboard");
|
||||
} else {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Completing authentication...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
52
apps/web/src/app/auth/oidc/callback/page.tsx
Normal file
52
apps/web/src/app/auth/oidc/callback/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
|
||||
export default function OIDCCallbackPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { setUser, setIsAuthenticated, setIsAdmin } = useAuth();
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
const response = await fetch("/api/auth/me", {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const { isAdmin, ...userData } = data.user;
|
||||
|
||||
setUser(userData);
|
||||
setIsAdmin(isAdmin);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
router.push("/dashboard");
|
||||
} else {
|
||||
throw new Error("Authentication failed");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("OIDC callback error:", error);
|
||||
router.push("/login?error=authentication_failed");
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [router, setUser, setIsAuthenticated, setIsAdmin]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<LoadingScreen />
|
||||
<p className="mt-4 text-muted-foreground">{t("login.processing")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -5,8 +5,9 @@ import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useFileManager } from "@/hooks/use-file-manager";
|
||||
import { useSecureConfigValue } from "@/hooks/use-secure-configs";
|
||||
import { useShareManager } from "@/hooks/use-share-manager";
|
||||
import { getAllConfigs, getDiskSpace, listFiles, listUserShares } from "@/http/endpoints";
|
||||
import { getDiskSpace, listFiles, listUserShares } from "@/http/endpoints";
|
||||
import { ListUserShares200SharesItem } from "@/http/models/listUserShares200SharesItem";
|
||||
|
||||
export function useDashboard() {
|
||||
@@ -20,7 +21,8 @@ export function useDashboard() {
|
||||
const [recentFiles, setRecentFiles] = useState<any[]>([]);
|
||||
const [recentShares, setRecentShares] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [smtpEnabled, setSmtpEnabled] = useState("false");
|
||||
|
||||
const { value: smtpEnabled } = useSecureConfigValue("smtpEnabled");
|
||||
|
||||
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
@@ -32,12 +34,7 @@ export function useDashboard() {
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
const [diskSpaceRes, filesRes, sharesRes, configsRes] = await Promise.all([
|
||||
getDiskSpace(),
|
||||
listFiles(),
|
||||
listUserShares(),
|
||||
getAllConfigs(),
|
||||
]);
|
||||
const [diskSpaceRes, filesRes, sharesRes] = await Promise.all([getDiskSpace(), listFiles(), listUserShares()]);
|
||||
|
||||
setDiskSpace(diskSpaceRes.data);
|
||||
|
||||
@@ -54,13 +51,8 @@ export function useDashboard() {
|
||||
);
|
||||
|
||||
setRecentShares(sortedShares.slice(0, 5));
|
||||
|
||||
const smtpConfig = configsRes.data.configs.find((config: any) => config.key === "smtpEnabled");
|
||||
|
||||
setSmtpEnabled(smtpConfig?.value === "true" ? "true" : "false");
|
||||
} catch (error) {
|
||||
toast.error(t("dashboard.loadError"));
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -98,6 +90,6 @@ export function useDashboard() {
|
||||
shareManager,
|
||||
handleCopyLink,
|
||||
loadDashboardData,
|
||||
smtpEnabled,
|
||||
smtpEnabled: smtpEnabled || "false",
|
||||
};
|
||||
}
|
||||
|
@@ -30,7 +30,6 @@ export function useFiles() {
|
||||
setFiles(sortedFiles);
|
||||
} catch (error) {
|
||||
toast.error(t("files.loadError"));
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
@@ -43,12 +43,10 @@ export default async function RootLayout({
|
||||
<NextIntlClientProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||
<AuthProvider>
|
||||
<ShareProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ShareProvider>
|
||||
<ShareProvider>{children}</ShareProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
<Toaster position="bottom-right" expand={false} richColors={false} closeButton={false} />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
@@ -8,6 +8,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { createLoginSchema, type LoginFormValues } from "../schemas/schema";
|
||||
import { PasswordVisibilityToggle } from "./password-visibility-toggle";
|
||||
import { SSOButton } from "./sso-button";
|
||||
|
||||
interface LoginFormProps {
|
||||
error?: string;
|
||||
@@ -96,6 +97,8 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<SSOButton />
|
||||
|
||||
<div className="flex w-full items-center justify-center px-1 mt-2">
|
||||
<Link className="text-muted-foreground hover:text-primary text-sm" href="/forgot-password">
|
||||
{t("login.forgotPassword")}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -12,6 +13,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { useAppInfo } from "@/contexts/app-info-context";
|
||||
import { registerUser, updateConfig } from "@/http/endpoints";
|
||||
import { PasswordVisibilityToggle } from "./password-visibility-toggle";
|
||||
import { SSOButton } from "./sso-button";
|
||||
|
||||
interface RegisterFormProps {
|
||||
isVisible: boolean;
|
||||
@@ -54,12 +56,17 @@ export function RegisterForm({ isVisible, onToggleVisibility }: RegisterFormProp
|
||||
await refreshAppInfo();
|
||||
toast.success(t("register.validation.success"));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t("register.validation.error"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
const renderErrorMessage = () => (
|
||||
<p className="text-destructive text-sm text-center bg-destructive/10 p-2 rounded-md">
|
||||
{t("register.validation.error")}
|
||||
</p>
|
||||
);
|
||||
|
||||
const renderForm = () => (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
<FormField
|
||||
@@ -68,8 +75,13 @@ export function RegisterForm({ isVisible, onToggleVisibility }: RegisterFormProp
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("register.labels.firstName")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} className="bg-transparent backdrop-blur-md" />
|
||||
<FormControl className="-mb-1">
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t("register.labels.firstName")}
|
||||
disabled={form.formState.isSubmitting}
|
||||
className="bg-transparent backdrop-blur-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -82,8 +94,13 @@ export function RegisterForm({ isVisible, onToggleVisibility }: RegisterFormProp
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("register.labels.lastName")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} className="bg-transparent backdrop-blur-md" />
|
||||
<FormControl className="-mb-1">
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t("register.labels.lastName")}
|
||||
disabled={form.formState.isSubmitting}
|
||||
className="bg-transparent backdrop-blur-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -96,8 +113,13 @@ export function RegisterForm({ isVisible, onToggleVisibility }: RegisterFormProp
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("register.labels.username")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} className="bg-transparent backdrop-blur-md" />
|
||||
<FormControl className="-mb-1">
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t("register.labels.username")}
|
||||
disabled={form.formState.isSubmitting}
|
||||
className="bg-transparent backdrop-blur-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -110,8 +132,14 @@ export function RegisterForm({ isVisible, onToggleVisibility }: RegisterFormProp
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("register.labels.email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="email" className="bg-transparent backdrop-blur-md" />
|
||||
<FormControl className="-mb-1">
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
placeholder={t("register.labels.email")}
|
||||
disabled={form.formState.isSubmitting}
|
||||
className="bg-transparent backdrop-blur-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -129,6 +157,8 @@ export function RegisterForm({ isVisible, onToggleVisibility }: RegisterFormProp
|
||||
<Input
|
||||
{...field}
|
||||
type={isVisible ? "text" : "password"}
|
||||
placeholder={t("register.labels.password")}
|
||||
disabled={form.formState.isSubmitting}
|
||||
className="bg-transparent backdrop-blur-md pr-10"
|
||||
/>
|
||||
<PasswordVisibilityToggle isVisible={isVisible} onToggle={onToggleVisibility} />
|
||||
@@ -139,10 +169,17 @@ export function RegisterForm({ isVisible, onToggleVisibility }: RegisterFormProp
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button className="w-full mt-4" type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Button className="w-full mt-4 cursor-pointer" variant="default" size="lg" type="submit">
|
||||
{form.formState.isSubmitting ? t("register.buttons.creating") : t("register.buttons.createAdmin")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderForm()}
|
||||
<SSOButton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
56
apps/web/src/app/login/components/sso-button.tsx
Normal file
56
apps/web/src/app/login/components/sso-button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useOIDC } from "../hooks/use-oidc";
|
||||
|
||||
interface SSOButtonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SSOButton({ className }: SSOButtonProps) {
|
||||
const t = useTranslations();
|
||||
const { config, isLoading, initiateLogin } = useOIDC();
|
||||
|
||||
if (isLoading || !config?.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative my-4">
|
||||
<Separator />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="bg-background px-2 text-muted-foreground text-sm">{t("login.or")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" className={`w-full ${className}`} onClick={initiateLogin} type="button">
|
||||
<svg className="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 2L2 7L12 12L22 7L12 2Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2 17L12 22L22 17"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2 12L12 17L22 12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{t("login.continueWithSSO")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import axios from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
@@ -19,12 +20,32 @@ export type LoginFormData = z.infer<typeof loginSchema>;
|
||||
|
||||
export function useLogin() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations();
|
||||
const { isAuthenticated, setUser, setIsAdmin, setIsAuthenticated } = useAuth();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const errorParam = searchParams.get("error");
|
||||
|
||||
if (errorParam) {
|
||||
const errorKey = `auth.errors.${errorParam}`;
|
||||
const message = t(errorKey);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.error(message);
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("error");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}, 1000);
|
||||
}
|
||||
}, [searchParams, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
|
58
apps/web/src/app/login/hooks/use-oidc.ts
Normal file
58
apps/web/src/app/login/hooks/use-oidc.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getOIDCConfig, initiateOIDCLogin } from "@/http/endpoints/auth";
|
||||
import type { OIDCConfigData } from "@/http/endpoints/auth/types";
|
||||
|
||||
interface UseOIDCReturn {
|
||||
config: OIDCConfigData | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
initiateLogin: () => void;
|
||||
}
|
||||
|
||||
export function useOIDC(): UseOIDCReturn {
|
||||
const [config, setConfig] = useState<OIDCConfigData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await getOIDCConfig();
|
||||
setConfig(response.data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch OIDC config:", err);
|
||||
setError("Failed to load SSO configuration");
|
||||
setConfig({ enabled: false });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const initiateLogin = () => {
|
||||
if (!config?.enabled || !config?.authUrl) {
|
||||
console.error("OIDC not properly configured");
|
||||
return;
|
||||
}
|
||||
|
||||
const state = crypto.randomUUID();
|
||||
|
||||
sessionStorage.setItem("oidc_state", state);
|
||||
|
||||
const authUrl = initiateOIDCLogin(state);
|
||||
window.location.href = authUrl;
|
||||
};
|
||||
|
||||
return {
|
||||
config,
|
||||
isLoading,
|
||||
error,
|
||||
initiateLogin,
|
||||
};
|
||||
}
|
@@ -66,7 +66,6 @@ export function useProfile() {
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(t("profile.errors.loadFailed"));
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -90,7 +89,6 @@ export function useProfile() {
|
||||
await loadUserData();
|
||||
} catch (error) {
|
||||
toast.error(t("profile.errors.updateFailed"));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -110,7 +108,6 @@ export function useProfile() {
|
||||
passwordForm.reset();
|
||||
} catch (error) {
|
||||
toast.error(t("profile.errors.passwordFailed"));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -126,7 +123,6 @@ export function useProfile() {
|
||||
toast.success(t("profile.messages.imageSuccess"));
|
||||
} catch (error) {
|
||||
toast.error(t("profile.errors.imageFailed"));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -142,7 +138,6 @@ export function useProfile() {
|
||||
toast.success(t("profile.messages.imageRemoved"));
|
||||
} catch (error) {
|
||||
toast.error(t("profile.errors.imageRemoveFailed"));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
|
@@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export interface FileSizeInputProps {
|
||||
value: string; // valor em bytes
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
error?: any;
|
||||
@@ -20,12 +20,10 @@ const UNIT_MULTIPLIERS: Record<Unit, number> = {
|
||||
function bytesToHumanReadable(bytes: string): { value: string; unit: Unit } {
|
||||
const numBytes = parseInt(bytes, 10);
|
||||
|
||||
// Se for 0 ou inválido, retorna 0 GB
|
||||
if (!numBytes || numBytes <= 0) {
|
||||
return { value: "0", unit: "GB" };
|
||||
}
|
||||
|
||||
// Verifica TB (com tolerância para valores próximos)
|
||||
if (numBytes >= UNIT_MULTIPLIERS.TB) {
|
||||
const tbValue = numBytes / UNIT_MULTIPLIERS.TB;
|
||||
if (tbValue === Math.floor(tbValue)) {
|
||||
@@ -36,7 +34,6 @@ function bytesToHumanReadable(bytes: string): { value: string; unit: Unit } {
|
||||
}
|
||||
}
|
||||
|
||||
// Verifica GB (com tolerância para valores próximos)
|
||||
if (numBytes >= UNIT_MULTIPLIERS.GB) {
|
||||
const gbValue = numBytes / UNIT_MULTIPLIERS.GB;
|
||||
if (gbValue === Math.floor(gbValue)) {
|
||||
@@ -47,7 +44,6 @@ function bytesToHumanReadable(bytes: string): { value: string; unit: Unit } {
|
||||
}
|
||||
}
|
||||
|
||||
// Verifica MB
|
||||
if (numBytes >= UNIT_MULTIPLIERS.MB) {
|
||||
const mbValue = numBytes / UNIT_MULTIPLIERS.MB;
|
||||
return {
|
||||
@@ -56,7 +52,6 @@ function bytesToHumanReadable(bytes: string): { value: string; unit: Unit } {
|
||||
};
|
||||
}
|
||||
|
||||
// Para valores menores que 1MB, converte para MB com decimais
|
||||
const mbValue = numBytes / UNIT_MULTIPLIERS.MB;
|
||||
return {
|
||||
value: mbValue.toFixed(3),
|
||||
@@ -77,7 +72,6 @@ export function FileSizeInput({ value, onChange, disabled = false, error }: File
|
||||
const [displayValue, setDisplayValue] = useState("");
|
||||
const [selectedUnit, setSelectedUnit] = useState<Unit>("GB");
|
||||
|
||||
// Inicializar os valores quando o componente monta ou o value muda
|
||||
useEffect(() => {
|
||||
if (value && value !== "0") {
|
||||
const { value: humanValue, unit } = bytesToHumanReadable(value);
|
||||
@@ -90,10 +84,8 @@ export function FileSizeInput({ value, onChange, disabled = false, error }: File
|
||||
}, [value]);
|
||||
|
||||
const handleValueChange = (newValue: string) => {
|
||||
// Só permitir números e ponto decimal
|
||||
const sanitizedValue = newValue.replace(/[^0-9.]/g, "");
|
||||
|
||||
// Prevenir múltiplos pontos decimais
|
||||
const parts = sanitizedValue.split(".");
|
||||
const finalValue = parts.length > 2 ? parts[0] + "." + parts.slice(1).join("") : sanitizedValue;
|
||||
|
||||
|
@@ -42,7 +42,6 @@ export function LogoInput({ value, onChange, isDisabled }: LogoInputProps) {
|
||||
toast.success(t("logo.messages.uploadSuccess"));
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || t("logo.errors.uploadFailed"));
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
@@ -61,7 +60,6 @@ export function LogoInput({ value, onChange, isDisabled }: LogoInputProps) {
|
||||
toast.success(t("logo.messages.removeSuccess"));
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || t("logo.errors.removeFailed"));
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
|
69
apps/web/src/app/settings/components/redirect-uri-input.tsx
Normal file
69
apps/web/src/app/settings/components/redirect-uri-input.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { forwardRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface RedirectUriInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
error?: any;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const CALLBACK_PATH = "/api/auth/oidc/callback";
|
||||
|
||||
export const RedirectUriInput = forwardRef<HTMLInputElement, RedirectUriInputProps>(
|
||||
({ value, onChange, disabled, error, placeholder }, ref) => {
|
||||
const t = useTranslations();
|
||||
|
||||
const getBaseUrl = (fullUrl: string) => {
|
||||
if (!fullUrl) return "";
|
||||
return fullUrl.replace(CALLBACK_PATH, "");
|
||||
};
|
||||
|
||||
const buildFullUrl = (baseUrl: string) => {
|
||||
if (!baseUrl) return "";
|
||||
const cleanBaseUrl = baseUrl.replace(/\/$/, "");
|
||||
return `${cleanBaseUrl}${CALLBACK_PATH}`;
|
||||
};
|
||||
|
||||
const baseUrl = getBaseUrl(value || "");
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newBaseUrl = e.target.value;
|
||||
const fullUrl = buildFullUrl(newBaseUrl);
|
||||
onChange(fullUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={ref}
|
||||
value={baseUrl}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholder || t("settings.redirectUri.placeholder")}
|
||||
disabled={disabled}
|
||||
aria-invalid={!!error}
|
||||
className="pr-32"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<span className="text-xs text-muted-foreground font-mono bg-muted px-2 py-1 rounded border">
|
||||
{CALLBACK_PATH}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{baseUrl && (
|
||||
<div className="text-xs text-muted-foreground bg-muted/30 border border-muted rounded-md p-3">
|
||||
<div className="font-medium mb-1 text-foreground">{t("settings.redirectUri.previewLabel")}</div>
|
||||
<code className="text-foreground break-all font-mono">{buildFullUrl(baseUrl)}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
RedirectUriInput.displayName = "RedirectUriInput";
|
@@ -1,7 +1,7 @@
|
||||
import { SettingsFormProps, ValidGroup } from "../types";
|
||||
import { SettingsGroup } from "./settings-group";
|
||||
|
||||
const GROUP_ORDER: ValidGroup[] = ["general", "email", "security", "storage"];
|
||||
const GROUP_ORDER: ValidGroup[] = ["general", "email", "oidc", "security", "storage"];
|
||||
|
||||
export function SettingsForm({
|
||||
groupedConfigs,
|
||||
|
@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { createFieldDescriptions, createGroupMetadata } from "../constants";
|
||||
import { SettingsGroupProps } from "../types";
|
||||
import { SettingsInput } from "./settings-input";
|
||||
import { isFieldHidden, SettingsInput } from "./settings-input";
|
||||
|
||||
export function SettingsGroup({ group, configs, form, isCollapsed, onToggleCollapse, onSubmit }: SettingsGroupProps) {
|
||||
const t = useTranslations();
|
||||
@@ -46,26 +46,29 @@ export function SettingsGroup({ group, configs, form, isCollapsed, onToggleColla
|
||||
<CardContent className={`${isCollapsed ? "hidden" : "block"} px-0`}>
|
||||
<Separator className="my-6" />
|
||||
<div className="flex flex-col gap-4">
|
||||
{configs.map((config) => (
|
||||
<div key={config.key} className="space-y-2 mb-3">
|
||||
<SettingsInput
|
||||
config={config}
|
||||
error={form.formState.errors.configs?.[config.key]}
|
||||
register={form.register}
|
||||
setValue={form.setValue}
|
||||
smtpEnabled={form.watch("configs.smtpEnabled")}
|
||||
watch={form.watch}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground ml-1">
|
||||
{t(`settings.fields.${config.key}.description`, {
|
||||
defaultValue:
|
||||
FIELD_DESCRIPTIONS[config.key as keyof typeof FIELD_DESCRIPTIONS] ||
|
||||
config.description ||
|
||||
t("settings.fields.noDescription"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{configs
|
||||
.filter((config) => !isFieldHidden(config.key))
|
||||
.map((config) => (
|
||||
<div key={config.key} className="space-y-2 mb-3">
|
||||
<SettingsInput
|
||||
config={config}
|
||||
error={form.formState.errors.configs?.[config.key]}
|
||||
register={form.register}
|
||||
setValue={form.setValue}
|
||||
smtpEnabled={form.watch("configs.smtpEnabled")}
|
||||
oidcEnabled={form.watch("configs.oidcEnabled")}
|
||||
watch={form.watch}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground ml-1">
|
||||
{t(`settings.fields.${config.key}.description`, {
|
||||
defaultValue:
|
||||
FIELD_DESCRIPTIONS[config.key as keyof typeof FIELD_DESCRIPTIONS] ||
|
||||
config.description ||
|
||||
t("settings.fields.noDescription"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button
|
||||
|
@@ -1,11 +1,15 @@
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { UseFormRegister, UseFormSetValue, UseFormWatch } from "react-hook-form";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { TagsInput } from "@/components/ui/tags-input";
|
||||
import { createFieldTitles } from "../constants";
|
||||
import { Config } from "../types";
|
||||
import { FileSizeInput } from "./file-size-input";
|
||||
import { LogoInput } from "./logo-input";
|
||||
import { RedirectUriInput } from "./redirect-uri-input";
|
||||
|
||||
export interface ConfigInputProps {
|
||||
config: Config;
|
||||
@@ -14,13 +18,30 @@ export interface ConfigInputProps {
|
||||
setValue: UseFormSetValue<any>;
|
||||
error?: any;
|
||||
smtpEnabled?: string;
|
||||
oidcEnabled?: string;
|
||||
}
|
||||
|
||||
export function SettingsInput({ config, register, watch, setValue, error, smtpEnabled }: ConfigInputProps) {
|
||||
const TAGS_FIELDS = ["oidcScope", "oidcAdminEmailDomains"];
|
||||
const HIDDEN_FIELDS = ["serverUrl", "firstUserAccess"];
|
||||
|
||||
export function isFieldHidden(fieldKey: string): boolean {
|
||||
return HIDDEN_FIELDS.includes(fieldKey);
|
||||
}
|
||||
|
||||
export function SettingsInput({
|
||||
config,
|
||||
register,
|
||||
watch,
|
||||
setValue,
|
||||
error,
|
||||
smtpEnabled,
|
||||
oidcEnabled,
|
||||
}: ConfigInputProps) {
|
||||
const t = useTranslations();
|
||||
const FIELD_TITLES = createFieldTitles(t);
|
||||
const isSmtpField = config.group === "email" && config.key !== "smtpEnabled";
|
||||
const isDisabled = isSmtpField && smtpEnabled === "false";
|
||||
const isOidcField = config.group === "oidc" && config.key !== "oidcEnabled";
|
||||
const isDisabled = (isSmtpField && smtpEnabled === "false") || (isOidcField && oidcEnabled === "false");
|
||||
const friendlyLabel = FIELD_TITLES[config.key as keyof ReturnType<typeof createFieldTitles>] || config.key;
|
||||
|
||||
if (config.key === "appLogo") {
|
||||
@@ -41,7 +62,25 @@ export function SettingsInput({ config, register, watch, setValue, error, smtpEn
|
||||
);
|
||||
}
|
||||
|
||||
// Special input for file size configurations
|
||||
if (config.key === "oidcRedirectUri") {
|
||||
const value = watch(`configs.${config.key}`);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-semibold">{friendlyLabel}</label>
|
||||
<RedirectUriInput
|
||||
value={value || ""}
|
||||
onChange={(value) => {
|
||||
setValue(`configs.${config.key}`, value, { shouldDirty: true });
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
error={error}
|
||||
/>
|
||||
{error && <p className="text-danger text-xs mt-1">{error.message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (config.key === "maxFileSize" || config.key === "maxTotalStoragePerUser") {
|
||||
const value = watch(`configs.${config.key}`);
|
||||
|
||||
@@ -61,43 +100,109 @@ export function SettingsInput({ config, register, watch, setValue, error, smtpEn
|
||||
);
|
||||
}
|
||||
|
||||
if (TAGS_FIELDS.includes(config.key)) {
|
||||
const value = watch(`configs.${config.key}`);
|
||||
const tagsValue = value ? value.split(" ").filter(Boolean) : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="block text-sm font-semibold">{friendlyLabel}</label>
|
||||
<div className="relative group">
|
||||
<IconInfoCircle className="h-4 w-4 text-muted-foreground hover:text-foreground cursor-help transition-colors" />
|
||||
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 px-3 py-2 bg-popover text-popover-foreground text-xs rounded-md border shadow-md opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50">
|
||||
{config.key === "oidcScope"
|
||||
? t("settings.tooltips.oidcScope")
|
||||
: t("settings.tooltips.oidcAdminEmailDomains")}
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-popover"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TagsInput
|
||||
value={tagsValue}
|
||||
onChange={(tags) => {
|
||||
setValue(`configs.${config.key}`, tags.join(" "), { shouldDirty: true });
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
placeholder={
|
||||
config.key === "oidcScope"
|
||||
? "openid profile email"
|
||||
: config.key === "oidcAdminEmailDomains"
|
||||
? "admin.com company.org"
|
||||
: "Digite e pressione Enter"
|
||||
}
|
||||
/>
|
||||
{error && <p className="text-danger text-xs mt-1">{error.message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (config.type) {
|
||||
case "boolean":
|
||||
return (
|
||||
<select
|
||||
{...register(`configs.${config.key}`)}
|
||||
className="w-full rounded-md border border-input bg-transparent px-3 py-2"
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<option value="true">{t("common.yes")}</option>
|
||||
<option value="false">{t("common.no")}</option>
|
||||
</select>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-semibold">{friendlyLabel}</label>
|
||||
<select
|
||||
{...register(`configs.${config.key}`)}
|
||||
className="w-full rounded-md border border-input bg-transparent px-3 py-2"
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<option value="true">{t("common.yes")}</option>
|
||||
<option value="false">{t("common.no")}</option>
|
||||
</select>
|
||||
{error && <p className="text-danger text-xs mt-1">{error.message}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "number":
|
||||
case "bigint":
|
||||
return (
|
||||
<Input
|
||||
{...register(`configs.${config.key}`, {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
className="w-full"
|
||||
disabled={isDisabled}
|
||||
aria-invalid={!!error}
|
||||
type="number"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-semibold">{friendlyLabel}</label>
|
||||
<Input
|
||||
{...register(`configs.${config.key}`, {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
className="w-full"
|
||||
disabled={isDisabled}
|
||||
aria-invalid={!!error}
|
||||
type="number"
|
||||
/>
|
||||
{error && <p className="text-danger text-xs mt-1">{error.message}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "text":
|
||||
default:
|
||||
const isPasswordField = config.key.includes("Pass") || config.key.includes("Secret");
|
||||
|
||||
if (isPasswordField) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-semibold">{friendlyLabel}</label>
|
||||
<PasswordInput
|
||||
{...register(`configs.${config.key}`)}
|
||||
className="w-full"
|
||||
disabled={isDisabled}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
{error && <p className="text-danger text-xs mt-1">{error.message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...register(`configs.${config.key}`)}
|
||||
className="w-full"
|
||||
disabled={isDisabled}
|
||||
aria-invalid={!!error}
|
||||
type="text"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-semibold">{friendlyLabel}</label>
|
||||
<Input
|
||||
{...register(`configs.${config.key}`)}
|
||||
className="w-full"
|
||||
disabled={isDisabled}
|
||||
aria-invalid={!!error}
|
||||
type="text"
|
||||
/>
|
||||
{error && <p className="text-danger text-xs mt-1">{error.message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { IconDatabase, IconMail, IconSettings, IconShield } from "@tabler/icons-react";
|
||||
import { IconDatabase, IconKey, IconMail, IconSettings, IconShield } from "@tabler/icons-react";
|
||||
import { createTranslator } from "next-intl";
|
||||
|
||||
export const createGroupMetadata = (t: ReturnType<typeof createTranslator>) => ({
|
||||
@@ -12,6 +12,11 @@ export const createGroupMetadata = (t: ReturnType<typeof createTranslator>) => (
|
||||
description: t("settings.groups.general.description"),
|
||||
icon: IconSettings,
|
||||
},
|
||||
oidc: {
|
||||
title: t("settings.groups.oidc.title"),
|
||||
description: t("settings.groups.oidc.description"),
|
||||
icon: IconKey,
|
||||
},
|
||||
security: {
|
||||
title: t("settings.groups.security.title"),
|
||||
description: t("settings.groups.security.description"),
|
||||
@@ -30,6 +35,8 @@ export const createFieldDescriptions = (t: ReturnType<typeof createTranslator>)
|
||||
appName: t("settings.fields.appName.description"),
|
||||
appDescription: t("settings.fields.appDescription.description"),
|
||||
showHomePage: t("settings.fields.showHomePage.description"),
|
||||
firstUserAccess: t("settings.fields.firstUserAccess.description"),
|
||||
serverUrl: t("settings.fields.serverUrl.description"),
|
||||
|
||||
// Email settings
|
||||
smtpEnabled: t("settings.fields.smtpEnabled.description"),
|
||||
@@ -40,6 +47,16 @@ export const createFieldDescriptions = (t: ReturnType<typeof createTranslator>)
|
||||
smtpFromName: t("settings.fields.smtpFromName.description"),
|
||||
smtpFromEmail: t("settings.fields.smtpFromEmail.description"),
|
||||
|
||||
// OIDC settings (nomes corretos do seed)
|
||||
oidcEnabled: t("settings.fields.oidcEnabled.description"),
|
||||
oidcIssuerUrl: t("settings.fields.oidcIssuerUrl.description"),
|
||||
oidcClientId: t("settings.fields.oidcClientId.description"),
|
||||
oidcClientSecret: t("settings.fields.oidcClientSecret.description"),
|
||||
oidcRedirectUri: t("settings.fields.oidcRedirectUri.description"),
|
||||
oidcScope: t("settings.fields.oidcScope.description"),
|
||||
oidcAutoRegister: t("settings.fields.oidcAutoRegister.description"),
|
||||
oidcAdminEmailDomains: t("settings.fields.oidcAdminEmailDomains.description"),
|
||||
|
||||
// Security settings
|
||||
maxLoginAttempts: t("settings.fields.maxLoginAttempts.description"),
|
||||
loginBlockDuration: t("settings.fields.loginBlockDuration.description"),
|
||||
@@ -57,6 +74,8 @@ export const createFieldTitles = (t: ReturnType<typeof createTranslator>) => ({
|
||||
appName: t("settings.fields.appName.title"),
|
||||
appDescription: t("settings.fields.appDescription.title"),
|
||||
showHomePage: t("settings.fields.showHomePage.title"),
|
||||
firstUserAccess: t("settings.fields.firstUserAccess.title"),
|
||||
serverUrl: t("settings.fields.serverUrl.title"),
|
||||
|
||||
// Email settings
|
||||
smtpEnabled: t("settings.fields.smtpEnabled.title"),
|
||||
@@ -67,6 +86,16 @@ export const createFieldTitles = (t: ReturnType<typeof createTranslator>) => ({
|
||||
smtpFromName: t("settings.fields.smtpFromName.title"),
|
||||
smtpFromEmail: t("settings.fields.smtpFromEmail.title"),
|
||||
|
||||
// OIDC settings
|
||||
oidcEnabled: t("settings.fields.oidcEnabled.title"),
|
||||
oidcIssuerUrl: t("settings.fields.oidcIssuerUrl.title"),
|
||||
oidcClientId: t("settings.fields.oidcClientId.title"),
|
||||
oidcClientSecret: t("settings.fields.oidcClientSecret.title"),
|
||||
oidcRedirectUri: t("settings.fields.oidcRedirectUri.title"),
|
||||
oidcScope: t("settings.fields.oidcScope.title"),
|
||||
oidcAutoRegister: t("settings.fields.oidcAutoRegister.title"),
|
||||
oidcAdminEmailDomains: t("settings.fields.oidcAdminEmailDomains.title"),
|
||||
|
||||
// Security settings
|
||||
maxLoginAttempts: t("settings.fields.maxLoginAttempts.title"),
|
||||
loginBlockDuration: t("settings.fields.loginBlockDuration.title"),
|
||||
|
@@ -9,7 +9,8 @@ import { z } from "zod";
|
||||
|
||||
import { useAppInfo } from "@/contexts/app-info-context";
|
||||
import { useShareContext } from "@/contexts/share-context";
|
||||
import { bulkUpdateConfigs, getAllConfigs } from "@/http/endpoints";
|
||||
import { useAdminConfigs } from "@/hooks/use-secure-configs";
|
||||
import { bulkUpdateConfigs } from "@/http/endpoints";
|
||||
import { Config, ConfigType, GroupFormData } from "../types";
|
||||
|
||||
const createSchemas = () => ({
|
||||
@@ -27,31 +28,39 @@ export function useSettings() {
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({
|
||||
general: true,
|
||||
email: true,
|
||||
oidc: true,
|
||||
security: true,
|
||||
storage: true,
|
||||
});
|
||||
const { refreshAppInfo } = useAppInfo();
|
||||
const { refreshShareContext } = useShareContext();
|
||||
|
||||
const {
|
||||
configs: adminConfigsList,
|
||||
isLoading: configsLoading,
|
||||
error: configsError,
|
||||
isUnauthorized,
|
||||
reload: reloadConfigs,
|
||||
} = useAdminConfigs();
|
||||
|
||||
const groupForms = {
|
||||
general: useForm<GroupFormData>({ resolver: zodResolver(settingsSchema) }),
|
||||
email: useForm<GroupFormData>({ resolver: zodResolver(settingsSchema) }),
|
||||
oidc: useForm<GroupFormData>({ resolver: zodResolver(settingsSchema) }),
|
||||
security: useForm<GroupFormData>({ resolver: zodResolver(settingsSchema) }),
|
||||
storage: useForm<GroupFormData>({ resolver: zodResolver(settingsSchema) }),
|
||||
} as const;
|
||||
|
||||
type ValidGroup = keyof typeof groupForms;
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const response = await getAllConfigs();
|
||||
const configsData = response.data.configs.reduce((acc: Record<string, string>, config) => {
|
||||
useEffect(() => {
|
||||
if (!configsLoading && adminConfigsList.length > 0) {
|
||||
const configsData = adminConfigsList.reduce((acc: Record<string, string>, config) => {
|
||||
acc[config.key] = config.value;
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const grouped = response.data.configs.reduce((acc: Record<string, Config[]>, config) => {
|
||||
const grouped = adminConfigsList.reduce((acc: Record<string, Config[]>, config) => {
|
||||
const group = config.group || "general";
|
||||
|
||||
if (!acc[group]) acc[group] = [];
|
||||
@@ -72,6 +81,11 @@ export function useSettings() {
|
||||
if (b.key === "smtpEnabled") return 1;
|
||||
}
|
||||
|
||||
if (group === "oidc") {
|
||||
if (a.key === "oidcEnabled") return -1;
|
||||
if (b.key === "oidcEnabled") return 1;
|
||||
}
|
||||
|
||||
return a.key.localeCompare(b.key);
|
||||
});
|
||||
|
||||
@@ -82,12 +96,17 @@ export function useSettings() {
|
||||
setGroupedConfigs(grouped);
|
||||
|
||||
Object.entries(grouped).forEach(([groupName, groupConfigs]) => {
|
||||
if (groupName === "general" || groupName === "email" || groupName === "security" || groupName === "storage") {
|
||||
if (
|
||||
groupName === "general" ||
|
||||
groupName === "email" ||
|
||||
groupName === "oidc" ||
|
||||
groupName === "security" ||
|
||||
groupName === "storage"
|
||||
) {
|
||||
const group = groupName as ValidGroup;
|
||||
const groupConfigData = groupConfigs.reduce(
|
||||
(acc, config) => {
|
||||
acc[config.key] = configsData[config.key];
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
@@ -96,13 +115,10 @@ export function useSettings() {
|
||||
groupForms[group].reset({ configs: groupConfigData });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(t("settings.errors.loadFailed"));
|
||||
console.error(error);
|
||||
} finally {
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [configsLoading, adminConfigsList]);
|
||||
|
||||
const onGroupSubmit = async (group: ValidGroup, data: GroupFormData) => {
|
||||
try {
|
||||
@@ -125,8 +141,9 @@ export function useSettings() {
|
||||
}
|
||||
|
||||
await bulkUpdateConfigs(configsToUpdate);
|
||||
toast.success(t("settings.messages.updateSuccess", { group: t(`settings.groups.${group}`) }));
|
||||
await loadConfigs();
|
||||
toast.success(t("settings.messages.updateSuccess", { group: t(`settings.groups.${group}.title`) }));
|
||||
|
||||
await reloadConfigs();
|
||||
|
||||
if (group === "email") {
|
||||
await refreshShareContext();
|
||||
@@ -135,7 +152,6 @@ export function useSettings() {
|
||||
await refreshAppInfo();
|
||||
} catch (error) {
|
||||
toast.error(t("settings.errors.updateFailed"));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -146,10 +162,6 @@ export function useSettings() {
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigs();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
groupedConfigs,
|
||||
@@ -157,5 +169,7 @@ export function useSettings() {
|
||||
groupForms,
|
||||
toggleCollapse,
|
||||
onGroupSubmit,
|
||||
error: configsError,
|
||||
isUnauthorized,
|
||||
};
|
||||
}
|
||||
|
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { IconAlertTriangle, IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
import { ProtectedRoute } from "@/components/auth/protected-route";
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { Navbar } from "@/components/layout/navbar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DefaultFooter } from "@/components/ui/default-footer";
|
||||
import { SettingsForm } from "./components/settings-form";
|
||||
import { SettingsHeader } from "./components/settings-header";
|
||||
@@ -15,6 +19,76 @@ export default function SettingsPage() {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (settings.isUnauthorized) {
|
||||
return (
|
||||
<ProtectedRoute requireAdmin>
|
||||
<div className="w-full h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<div className="flex-1 max-w-7xl mx-auto w-full px-6 py-8">
|
||||
<div className="flex flex-col gap-8 items-center justify-center min-h-[50vh]">
|
||||
<Card className="max-w-md border-destructive/50 bg-destructive/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive flex items-center gap-2">
|
||||
<IconAlertTriangle className="h-5 w-5" />
|
||||
Access Denied
|
||||
</CardTitle>
|
||||
<CardDescription className="text-destructive/80">
|
||||
{settings.error || "You don't have administrator privileges to access this page."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="outline"
|
||||
className="w-full flex items-center gap-2"
|
||||
>
|
||||
<IconRefresh className="h-4 w-4" />
|
||||
Refresh Page
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<DefaultFooter />
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
if (settings.error && !settings.isUnauthorized) {
|
||||
return (
|
||||
<ProtectedRoute requireAdmin>
|
||||
<div className="w-full h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<div className="flex-1 max-w-7xl mx-auto w-full px-6 py-8">
|
||||
<div className="flex flex-col gap-8 items-center justify-center min-h-[50vh]">
|
||||
<Card className="max-w-md border-destructive/50 bg-destructive/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive flex items-center gap-2">
|
||||
<IconAlertTriangle className="h-5 w-5" />
|
||||
Error Loading Settings
|
||||
</CardTitle>
|
||||
<CardDescription className="text-destructive/80">{settings.error}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="outline"
|
||||
className="w-full flex items-center gap-2"
|
||||
>
|
||||
<IconRefresh className="h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<DefaultFooter />
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute requireAdmin>
|
||||
<div className="w-full h-screen flex flex-col">
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
|
||||
export type ValidGroup = "security" | "email" | "general" | "storage";
|
||||
export type ValidGroup = "security" | "email" | "general" | "storage" | "oidc";
|
||||
|
||||
export interface SettingsFormProps {
|
||||
groupedConfigs: Record<string, Config[]>;
|
||||
|
@@ -58,7 +58,6 @@ export function useUserManagement() {
|
||||
setUsers(response.data);
|
||||
} catch (error) {
|
||||
toast.error(t("users.errors.loadFailed"));
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -112,7 +111,6 @@ export function useUserManagement() {
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
toast.error(t("users.errors.submitFailed", { mode: t(`users.modes.${modalMode}`) }));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -126,7 +124,6 @@ export function useUserManagement() {
|
||||
onDeleteModalClose();
|
||||
} catch (error) {
|
||||
toast.error(t("users.errors.deleteFailed"));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -145,7 +142,6 @@ export function useUserManagement() {
|
||||
onStatusModalClose();
|
||||
} catch (error) {
|
||||
toast.error(t("users.errors.statusUpdateFailed"));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
|
@@ -42,7 +42,6 @@ export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: Fil
|
||||
setShareFiles(allFiles.filter((file) => selectedFiles.includes(file.id)));
|
||||
setAvailableFiles(allFiles.filter((file) => !selectedFiles.includes(file.id)));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to load files");
|
||||
}
|
||||
};
|
||||
@@ -80,7 +79,6 @@ export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: Fil
|
||||
|
||||
await onSave(shareFiles.map((f) => f.id));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to update files");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -91,7 +89,6 @@ export function FileSelector({ shareId, selectedFiles, onSave, onEditFile }: Fil
|
||||
if (onEditFile) {
|
||||
await onEditFile(fileId, newName, description);
|
||||
setFileToEdit(null);
|
||||
// Recarregar arquivos para mostrar as mudanças
|
||||
await loadFiles();
|
||||
}
|
||||
};
|
||||
|
@@ -32,7 +32,7 @@ const languages = {
|
||||
};
|
||||
|
||||
const COOKIE_LANG_KEY = "NEXT_LOCALE";
|
||||
const COOKIE_MAX_AGE = 365 * 24 * 60 * 60; // 1 year
|
||||
const COOKIE_MAX_AGE = 365 * 24 * 60 * 60;
|
||||
|
||||
const RTL_LANGUAGES = ["ar-SA"];
|
||||
|
||||
|
@@ -113,7 +113,6 @@ export function RecipientSelector({ shareId, selectedRecipients, shareAlias, onS
|
||||
toast.success(t("recipientSelector.bulkNotifySuccess", { count: emailsToNotify.length }));
|
||||
setSelectedForAction(new Set());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.dismiss(loadingToast);
|
||||
toast.error(t("recipientSelector.bulkNotifyError"));
|
||||
}
|
||||
@@ -130,7 +129,6 @@ export function RecipientSelector({ shareId, selectedRecipients, shareAlias, onS
|
||||
toast.dismiss(loadingToast);
|
||||
toast.success(t("recipientSelector.notifySuccess"));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.dismiss(loadingToast);
|
||||
toast.error(t("recipientSelector.notifyError"));
|
||||
}
|
||||
@@ -303,7 +301,6 @@ export function RecipientSelector({ shareId, selectedRecipients, shareAlias, onS
|
||||
toast.dismiss(loadingToast);
|
||||
toast.success(t("recipientSelector.singleNotifySuccess", { email }));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.dismiss(loadingToast);
|
||||
toast.error(t("recipientSelector.singleNotifyError"));
|
||||
}
|
||||
|
@@ -54,7 +54,6 @@ export function CreateShareModal({ isOpen, onClose, onSuccess }: CreateShareModa
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(t("createShare.error"));
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
@@ -57,7 +57,6 @@ export function GenerateShareLinkModal({
|
||||
onSuccess();
|
||||
toast.success(t("generateShareLink.success"));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t("generateShareLink.error"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
@@ -107,7 +107,6 @@ export function ShareActionsModals({
|
||||
onCloseEdit();
|
||||
toast.success(t("shareActions.editSuccess"));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t("shareActions.editError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
@@ -118,7 +118,6 @@ export function ShareDetailsModal({
|
||||
const response = await getShare(shareId);
|
||||
setShare(response.data.share);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t("shareDetails.loadError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -130,8 +129,6 @@ export function ShareDetailsModal({
|
||||
try {
|
||||
return format(new Date(dateString), "MM/dd/yyyy HH:mm");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error("Invalid date:", dateString);
|
||||
return t("shareDetails.invalidDate");
|
||||
}
|
||||
};
|
||||
|
@@ -39,7 +39,6 @@ export function ShareExpirationModal({ shareId, share, onClose, onSuccess }: Sha
|
||||
setHasExpiration(hasCurrentExpiration);
|
||||
|
||||
if (hasCurrentExpiration) {
|
||||
// Converter para formato datetime-local
|
||||
const date = new Date(share.expiration);
|
||||
setExpirationDate(date.toISOString().slice(0, 16));
|
||||
} else {
|
||||
@@ -51,7 +50,6 @@ export function ShareExpirationModal({ shareId, share, onClose, onSuccess }: Sha
|
||||
const handleSave = async () => {
|
||||
if (!shareId) return;
|
||||
|
||||
// Validação
|
||||
if (hasExpiration) {
|
||||
if (!expirationDate.trim()) {
|
||||
toast.error(t("shareExpiration.validation.dateRequired"));
|
||||
@@ -99,14 +97,12 @@ export function ShareExpirationModal({ shareId, share, onClose, onSuccess }: Sha
|
||||
if (!checked) {
|
||||
setExpirationDate("");
|
||||
} else if (!expirationDate) {
|
||||
// Definir data padrão para 7 dias no futuro
|
||||
const defaultDate = new Date();
|
||||
defaultDate.setDate(defaultDate.getDate() + 7);
|
||||
setExpirationDate(defaultDate.toISOString().slice(0, 16));
|
||||
}
|
||||
};
|
||||
|
||||
// Determina o texto do botão
|
||||
const getButtonText = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -128,7 +124,6 @@ export function ShareExpirationModal({ shareId, share, onClose, onSuccess }: Sha
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-6">
|
||||
{/* Status Atual */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-foreground">{t("shareExpiration.currentStatus")}</h3>
|
||||
<div className="flex gap-2">
|
||||
@@ -146,7 +141,6 @@ export function ShareExpirationModal({ shareId, share, onClose, onSuccess }: Sha
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuração de Expiração */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="expiration-enabled" checked={hasExpiration} onCheckedChange={handleExpirationToggle} />
|
||||
|
@@ -70,8 +70,6 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Criar o compartilhamento
|
||||
const shareResponse = await createShare({
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
@@ -84,14 +82,12 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
|
||||
const newShareId = shareResponse.data.share.id;
|
||||
setShareId(newShareId);
|
||||
|
||||
// Adicionar o arquivo ao compartilhamento
|
||||
await addFiles(newShareId, { files: [file.id] });
|
||||
|
||||
toast.success(t("createShare.success"));
|
||||
setStep("link");
|
||||
} catch (error) {
|
||||
toast.error(t("createShare.error"));
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -116,7 +112,6 @@ export function ShareFileModal({ isOpen, file, onClose, onSuccess }: ShareFileMo
|
||||
setGeneratedLink(link);
|
||||
toast.success(t("generateShareLink.success"));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t("generateShareLink.error"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
@@ -71,8 +71,6 @@ export function ShareMultipleFilesModal({ files, isOpen, onClose, onSuccess }: S
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Criar o compartilhamento
|
||||
const shareResponse = await createShare({
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
@@ -85,14 +83,12 @@ export function ShareMultipleFilesModal({ files, isOpen, onClose, onSuccess }: S
|
||||
const newShareId = shareResponse.data.share.id;
|
||||
setShareId(newShareId);
|
||||
|
||||
// Adicionar todos os arquivos ao compartilhamento
|
||||
await addFiles(newShareId, { files: files.map((f) => f.id) });
|
||||
|
||||
toast.success(t("createShare.success"));
|
||||
setStep("link");
|
||||
} catch (error) {
|
||||
toast.error(t("createShare.error"));
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -108,7 +104,6 @@ export function ShareMultipleFilesModal({ files, isOpen, onClose, onSuccess }: S
|
||||
setGeneratedLink(link);
|
||||
toast.success(t("generateShareLink.success"));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t("generateShareLink.error"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
@@ -39,7 +39,6 @@ export function ShareSecurityModal({ shareId, share, onClose, onSuccess }: Share
|
||||
if (share?.security) {
|
||||
const hasCurrentPassword = share.security.hasPassword || false;
|
||||
setHasPassword(hasCurrentPassword);
|
||||
// Campo sempre começa vazio
|
||||
setPassword("");
|
||||
}
|
||||
}, [share]);
|
||||
@@ -47,7 +46,6 @@ export function ShareSecurityModal({ shareId, share, onClose, onSuccess }: Share
|
||||
const handleSave = async () => {
|
||||
if (!shareId) return;
|
||||
|
||||
// Validação de senha
|
||||
if (hasPassword) {
|
||||
if (!password.trim()) {
|
||||
toast.error(t("shareSecurity.validation.passwordRequired"));
|
||||
@@ -97,7 +95,6 @@ export function ShareSecurityModal({ shareId, share, onClose, onSuccess }: Share
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
|
||||
// Determina o texto do botão
|
||||
const getButtonText = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -119,7 +116,6 @@ export function ShareSecurityModal({ shareId, share, onClose, onSuccess }: Share
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-6">
|
||||
{/* Status Atual */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-foreground">{t("shareSecurity.currentStatus")}</h3>
|
||||
<div className="flex gap-2">
|
||||
@@ -143,7 +139,6 @@ export function ShareSecurityModal({ shareId, share, onClose, onSuccess }: Share
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuração de Senha */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="password-protection" checked={hasPassword} onCheckedChange={handlePasswordToggle} />
|
||||
@@ -155,7 +150,6 @@ export function ShareSecurityModal({ shareId, share, onClose, onSuccess }: Share
|
||||
|
||||
{hasPassword && (
|
||||
<div className="space-y-4 pl-6 border-l-2 border-muted">
|
||||
{/* Mensagem explicativa quando já tem senha */}
|
||||
{share?.security?.hasPassword && (
|
||||
<div className="bg-muted/50 border border-border rounded-lg p-3">
|
||||
<p className="text-sm text-muted-foreground">{t("shareSecurity.existingPasswordMessage")}</p>
|
||||
@@ -201,7 +195,6 @@ export function ShareSecurityModal({ shareId, share, onClose, onSuccess }: Share
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Informações Adicionais */}
|
||||
<div className="bg-muted/30 p-3 rounded-lg">
|
||||
<div className="text-sm space-y-1">
|
||||
<p className="font-medium text-muted-foreground">{t("shareSecurity.info.title")}</p>
|
||||
|
@@ -98,7 +98,6 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Cleanup preview URLs
|
||||
fileUploads.forEach((upload) => {
|
||||
if (upload.previewUrl) {
|
||||
URL.revokeObjectURL(upload.previewUrl);
|
||||
@@ -137,7 +136,6 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
|
||||
const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleFilesSelect(event.target.files);
|
||||
// Clear input value to allow selecting the same files again
|
||||
if (event.target) {
|
||||
event.target.value = "";
|
||||
}
|
||||
@@ -199,10 +197,8 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
upload.abortController.abort();
|
||||
}
|
||||
|
||||
// If file was uploaded to server, try to delete it
|
||||
if (upload.objectName && upload.status === UploadStatus.UPLOADING) {
|
||||
try {
|
||||
// Here you would call an API to delete the uploaded file from server
|
||||
// await deleteUploadedFile(upload.objectName);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete uploaded file:", error);
|
||||
@@ -222,7 +218,6 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
const extension = fileName.split(".").pop() || "";
|
||||
const safeObjectName = generateSafeFileName(fileName);
|
||||
|
||||
// Check file before upload
|
||||
try {
|
||||
await checkFile({
|
||||
name: fileName,
|
||||
@@ -249,12 +244,10 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
return;
|
||||
}
|
||||
|
||||
// Set uploading status
|
||||
setFileUploads((prev) =>
|
||||
prev.map((u) => (u.id === id ? { ...u, status: UploadStatus.UPLOADING, progress: 0 } : u))
|
||||
);
|
||||
|
||||
// Get presigned URL
|
||||
const presignedResponse = await getPresignedUrl({
|
||||
filename: safeObjectName.replace(`.${extension}`, ""),
|
||||
extension: extension,
|
||||
@@ -262,14 +255,11 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
|
||||
const { url, objectName } = presignedResponse.data;
|
||||
|
||||
// Update with object name
|
||||
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, objectName } : u)));
|
||||
|
||||
// Create abort controller for this upload
|
||||
const abortController = new AbortController();
|
||||
setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, abortController } : u)));
|
||||
|
||||
// Upload file
|
||||
await axios.put(url, file, {
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
@@ -281,7 +271,6 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
},
|
||||
});
|
||||
|
||||
// Register file
|
||||
await registerFile({
|
||||
name: fileName,
|
||||
objectName: objectName,
|
||||
@@ -289,7 +278,6 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
extension: extension,
|
||||
});
|
||||
|
||||
// Set success status
|
||||
setFileUploads((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === id ? { ...u, status: UploadStatus.SUCCESS, progress: 100, abortController: undefined } : u
|
||||
@@ -297,7 +285,6 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
);
|
||||
} catch (error: any) {
|
||||
if (error.name === "AbortError" || error.code === "ERR_CANCELED") {
|
||||
// Upload was cancelled, status already set by cancelUpload
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -320,11 +307,9 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
const startUploads = async () => {
|
||||
const pendingUploads = fileUploads.filter((u) => u.status === UploadStatus.PENDING);
|
||||
|
||||
// Upload all files concurrently
|
||||
const uploadPromises = pendingUploads.map((upload) => uploadFile(upload));
|
||||
await Promise.all(uploadPromises);
|
||||
|
||||
// Check if all uploads are complete after a short delay to ensure state updates
|
||||
setTimeout(() => {
|
||||
setFileUploads((currentUploads) => {
|
||||
const allComplete = currentUploads.every(
|
||||
@@ -342,7 +327,6 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
? t("uploadFile.partialSuccess", { success: successCount, error: errorCount })
|
||||
: t("uploadFile.allSuccess", { count: successCount })
|
||||
);
|
||||
// Call onSuccess to refresh the file list
|
||||
onSuccess?.();
|
||||
}
|
||||
}
|
||||
@@ -363,14 +347,12 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
};
|
||||
|
||||
const handleConfirmClose = () => {
|
||||
// Cancel all ongoing uploads
|
||||
fileUploads.forEach((upload) => {
|
||||
if (upload.status === UploadStatus.UPLOADING && upload.abortController) {
|
||||
upload.abortController.abort();
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup preview URLs
|
||||
fileUploads.forEach((upload) => {
|
||||
if (upload.previewUrl) {
|
||||
URL.revokeObjectURL(upload.previewUrl);
|
||||
@@ -410,7 +392,6 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
<div className="flex-1 overflow-hidden flex flex-col gap-4">
|
||||
<input ref={fileInputRef} className="hidden" type="file" multiple onChange={handleFileInputChange} />
|
||||
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 cursor-pointer transition-colors ${
|
||||
isDragOver ? "border-primary bg-primary/10" : "border-border hover:border-primary/50"
|
||||
@@ -427,7 +408,6 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Files List */}
|
||||
{fileUploads.length > 0 && (
|
||||
<div className="flex-1 overflow-y-auto space-y-2 max-h-96">
|
||||
{fileUploads.map((upload) => (
|
||||
|
@@ -81,17 +81,14 @@ export function FilesTable({
|
||||
}
|
||||
}, [editingField]);
|
||||
|
||||
// Clear pending changes when files are updated
|
||||
useEffect(() => {
|
||||
setPendingChanges({});
|
||||
}, [files]);
|
||||
|
||||
// Clear selected files when files array changes
|
||||
useEffect(() => {
|
||||
setSelectedFiles(new Set());
|
||||
}, [files]);
|
||||
|
||||
// Register clearSelection callback with parent
|
||||
useEffect(() => {
|
||||
const clearSelection = () => setSelectedFiles(new Set());
|
||||
setClearSelectionCallback?.(clearSelection);
|
||||
@@ -110,7 +107,6 @@ export function FilesTable({
|
||||
const startEdit = (fileId: string, field: "name" | "description", currentValue: string) => {
|
||||
setEditingField({ fileId, field });
|
||||
if (field === "name") {
|
||||
// Only edit the name part, not the extension
|
||||
const { name } = splitFileName(currentValue);
|
||||
setEditValue(name);
|
||||
} else {
|
||||
@@ -128,7 +124,6 @@ export function FilesTable({
|
||||
const { extension } = splitFileName(file.name);
|
||||
const newFullName = editValue + extension;
|
||||
|
||||
// Update local state optimistically
|
||||
setPendingChanges((prev) => ({
|
||||
...prev,
|
||||
[fileId]: { ...prev[fileId], name: newFullName },
|
||||
@@ -137,7 +132,6 @@ export function FilesTable({
|
||||
onUpdateName(fileId, newFullName);
|
||||
}
|
||||
} else {
|
||||
// Update local state optimistically
|
||||
setPendingChanges((prev) => ({
|
||||
...prev,
|
||||
[fileId]: { ...prev[fileId], description: editValue },
|
||||
@@ -230,8 +224,6 @@ export function FilesTable({
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Don't clear selection here - let the individual handlers do it after their actions complete
|
||||
};
|
||||
|
||||
const showBulkActions = selectedFiles.size > 0 && (onBulkDelete || onBulkShare || onBulkDownload);
|
||||
|
@@ -87,17 +87,14 @@ export function SharesTable({
|
||||
}
|
||||
}, [editingField]);
|
||||
|
||||
// Clear pending changes when shares are updated
|
||||
useEffect(() => {
|
||||
setPendingChanges({});
|
||||
}, [shares]);
|
||||
|
||||
// Clear selected shares when shares array changes
|
||||
useEffect(() => {
|
||||
setSelectedShares(new Set());
|
||||
}, [shares]);
|
||||
|
||||
// Register clearSelection callback with parent
|
||||
useEffect(() => {
|
||||
const clearSelection = () => setSelectedShares(new Set());
|
||||
setClearSelectionCallback?.(clearSelection);
|
||||
@@ -113,7 +110,6 @@ export function SharesTable({
|
||||
|
||||
const { shareId, field } = editingField;
|
||||
|
||||
// Update local state optimistically
|
||||
setPendingChanges((prev) => ({
|
||||
...prev,
|
||||
[shareId]: { ...prev[shareId], [field]: editValue },
|
||||
|
43
apps/web/src/components/ui/password-input.tsx
Normal file
43
apps/web/src/components/ui/password-input.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React, { useState } from "react";
|
||||
import { IconEye, IconEyeOff } from "@tabler/icons-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PasswordInputProps extends Omit<React.ComponentProps<"input">, "type"> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(({ className, ...props }, ref) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const togglePasswordVisibility = () => {
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input type={showPassword ? "text" : "password"} className={cn("pr-10", className)} ref={ref} {...props} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full w-10 hover:bg-transparent"
|
||||
onClick={togglePasswordVisibility}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
{showPassword ? (
|
||||
<IconEyeOff className="h-4 w-4 text-muted-foreground hover:text-foreground" />
|
||||
) : (
|
||||
<IconEye className="h-4 w-4 text-muted-foreground hover:text-foreground" />
|
||||
)}
|
||||
<span className="sr-only">{showPassword ? "Hide password" : "Show password"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PasswordInput.displayName = "PasswordInput";
|
||||
|
||||
export { PasswordInput };
|
98
apps/web/src/components/ui/tags-input.tsx
Normal file
98
apps/web/src/components/ui/tags-input.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { KeyboardEvent, useState } from "react";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TagsInputProps {
|
||||
value: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TagsInput({ value = [], onChange, placeholder, disabled, className }: TagsInputProps) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" || e.key === " " || e.key === ",") {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
} else if (e.key === "Backspace" && inputValue === "" && value.length > 0) {
|
||||
e.preventDefault();
|
||||
removeTag(value.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
const newTag = inputValue.trim();
|
||||
if (newTag && !value.includes(newTag)) {
|
||||
onChange([...value, newTag]);
|
||||
setInputValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (index: number) => {
|
||||
const newTags = value.filter((_, i) => i !== index);
|
||||
onChange(newTags);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
if (inputValue.trim()) {
|
||||
addTag();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"flex-wrap gap-1 min-h-9 h-auto py-1",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{value.map((tag, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"flex items-center gap-1 pl-2 pr-1 h-6 text-xs mt-[1px] rounded-[6px]",
|
||||
"bg-slate-300 text-gray-800 border-slate-200 hover:text-gray-800",
|
||||
"dark:bg-slate-800 dark:text-slate-200 dark:border-slate-700",
|
||||
"hover:cursor-default"
|
||||
)}
|
||||
>
|
||||
<span>{tag}</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(index)}
|
||||
className="ml-1 rounded-sm hover:bg-background/50 dark:hover:bg-background/20 flex items-center justify-center transition-colors hover:cursor-pointer"
|
||||
>
|
||||
<IconX className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder={value.length === 0 ? placeholder : ""}
|
||||
disabled={disabled}
|
||||
className="flex-1 min-w-[80px] border-0 bg-transparent p-0 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -2,7 +2,7 @@
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
import { getAllConfigs } from "@/http/endpoints";
|
||||
import { useSecureConfigValue } from "@/hooks/use-secure-configs";
|
||||
|
||||
interface ShareContextType {
|
||||
smtpEnabled: string;
|
||||
@@ -16,25 +16,17 @@ const ShareContext = createContext<ShareContextType>({
|
||||
|
||||
export function ShareProvider({ children }: { children: React.ReactNode }) {
|
||||
const [smtpEnabled, setSmtpEnabled] = useState("false");
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const response = await getAllConfigs();
|
||||
const smtpConfig = response.data.configs.find((config: any) => config.key === "smtpEnabled");
|
||||
|
||||
setSmtpEnabled(smtpConfig?.value || "false");
|
||||
} catch (error) {
|
||||
console.error("Failed to load SMTP config:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshShareContext = async () => {
|
||||
await loadConfigs();
|
||||
};
|
||||
const { value: smtpValue, reload: reloadSmtpConfig } = useSecureConfigValue("smtpEnabled");
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigs();
|
||||
}, []);
|
||||
if (smtpValue !== null) {
|
||||
setSmtpEnabled(smtpValue);
|
||||
}
|
||||
}, [smtpValue]);
|
||||
|
||||
const refreshShareContext = async () => {
|
||||
await reloadSmtpConfig();
|
||||
};
|
||||
|
||||
return <ShareContext.Provider value={{ smtpEnabled, refreshShareContext }}>{children}</ShareContext.Provider>;
|
||||
}
|
||||
|
@@ -100,7 +100,6 @@ export function useFileManager(onRefresh: () => Promise<void>, clearSelection?:
|
||||
document.body.removeChild(link);
|
||||
toast.success(t("files.downloadStart"));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t("files.downloadError"));
|
||||
}
|
||||
};
|
||||
@@ -142,7 +141,6 @@ export function useFileManager(onRefresh: () => Promise<void>, clearSelection?:
|
||||
|
||||
const handleShareBulkSuccess = () => {
|
||||
setFilesToShare(null);
|
||||
// Clear selection after successful share
|
||||
if (clearSelectionCallback) {
|
||||
clearSelectionCallback();
|
||||
}
|
||||
@@ -157,11 +155,9 @@ export function useFileManager(onRefresh: () => Promise<void>, clearSelection?:
|
||||
try {
|
||||
toast.promise(
|
||||
(async () => {
|
||||
// Dynamically import JSZip
|
||||
const JSZip = (await import("jszip")).default;
|
||||
const zip = new JSZip();
|
||||
|
||||
// Download all files and add to zip
|
||||
const downloadPromises = files.map(async (file) => {
|
||||
try {
|
||||
const encodedObjectName = encodeURIComponent(file.objectName);
|
||||
@@ -183,10 +179,8 @@ export function useFileManager(onRefresh: () => Promise<void>, clearSelection?:
|
||||
|
||||
await Promise.all(downloadPromises);
|
||||
|
||||
// Generate ZIP blob
|
||||
const zipBlob = await zip.generateAsync({ type: "blob" });
|
||||
|
||||
// Download ZIP file
|
||||
const url = URL.createObjectURL(zipBlob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
@@ -196,7 +190,6 @@ export function useFileManager(onRefresh: () => Promise<void>, clearSelection?:
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// Clear selection after successful download
|
||||
if (clearSelectionCallback) {
|
||||
clearSelectionCallback();
|
||||
}
|
||||
@@ -223,7 +216,6 @@ export function useFileManager(onRefresh: () => Promise<void>, clearSelection?:
|
||||
setFilesToDelete(null);
|
||||
onRefresh();
|
||||
|
||||
// Clear selection after successful deletion
|
||||
if (clearSelectionCallback) {
|
||||
clearSelectionCallback();
|
||||
}
|
||||
|
130
apps/web/src/hooks/use-secure-configs.ts
Normal file
130
apps/web/src/hooks/use-secure-configs.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getAdminConfigs, getSecureConfigs, getSecureConfigValue } from "@/lib/actions/config";
|
||||
|
||||
interface Config {
|
||||
key: string;
|
||||
value: string;
|
||||
type: string;
|
||||
group: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para buscar configurações de forma segura
|
||||
* Substitui o uso direto de getAllConfigs que expunha dados sensíveis
|
||||
*/
|
||||
export function useSecureConfigs() {
|
||||
const [configs, setConfigs] = useState<Config[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await getSecureConfigs();
|
||||
setConfigs(data.configs);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
console.error("Error loading secure configs:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigs();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
configs,
|
||||
isLoading,
|
||||
error,
|
||||
reload: loadConfigs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para buscar configurações para administradores
|
||||
* REQUER PERMISSÕES DE ADMIN - retorna erro se usuário não for admin
|
||||
*/
|
||||
export function useAdminConfigs() {
|
||||
const [configs, setConfigs] = useState<Config[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isUnauthorized, setIsUnauthorized] = useState(false);
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setIsUnauthorized(false);
|
||||
|
||||
const data = await getAdminConfigs();
|
||||
setConfigs(data.configs);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
|
||||
if (errorMessage.includes("Unauthorized") || errorMessage.includes("Admin access required")) {
|
||||
setIsUnauthorized(true);
|
||||
setError("Access denied: Administrator privileges required");
|
||||
} else {
|
||||
setError(errorMessage);
|
||||
}
|
||||
|
||||
console.error("Error loading admin configs:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigs();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
configs,
|
||||
isLoading,
|
||||
error,
|
||||
isUnauthorized,
|
||||
reload: loadConfigs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para buscar um valor específico de configuração
|
||||
* Útil quando você só precisa de um valor específico (ex: smtpEnabled)
|
||||
*/
|
||||
export function useSecureConfigValue(key: string) {
|
||||
const [value, setValue] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadConfigValue = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const configValue = await getSecureConfigValue(key);
|
||||
setValue(configValue);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
console.error(`Error loading config value for ${key}:`, err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigValue();
|
||||
}, [key]);
|
||||
|
||||
return {
|
||||
value,
|
||||
isLoading,
|
||||
error,
|
||||
reload: loadConfigValue,
|
||||
};
|
||||
}
|
@@ -74,7 +74,6 @@ export function useShareManager(onSuccess: () => void) {
|
||||
setShareToDelete(null);
|
||||
} catch (error) {
|
||||
toast.error(t("shareManager.deleteError"));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,14 +93,12 @@ export function useShareManager(onSuccess: () => void) {
|
||||
setSharesToDelete(null);
|
||||
onSuccess();
|
||||
|
||||
// Clear selection after successful deletion
|
||||
if (clearSelectionCallback) {
|
||||
clearSelectionCallback();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.dismiss(loadingToast);
|
||||
toast.error(t("shareManager.bulkDeleteError"));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -113,7 +110,6 @@ export function useShareManager(onSuccess: () => void) {
|
||||
setShareToEdit(null);
|
||||
} catch (error) {
|
||||
toast.error(t("shareManager.updateError"));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -124,7 +120,6 @@ export function useShareManager(onSuccess: () => void) {
|
||||
toast.success(t("shareManager.updateSuccess"));
|
||||
} catch (error) {
|
||||
toast.error(t("shareManager.updateError"));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -135,7 +130,6 @@ export function useShareManager(onSuccess: () => void) {
|
||||
toast.success(t("shareManager.updateSuccess"));
|
||||
} catch (error) {
|
||||
toast.error(t("shareManager.updateError"));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -145,7 +139,6 @@ export function useShareManager(onSuccess: () => void) {
|
||||
toast.success(t("shareManager.securityUpdateSuccess"));
|
||||
} catch (error) {
|
||||
toast.error(t("shareManager.securityUpdateError"));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -155,7 +148,6 @@ export function useShareManager(onSuccess: () => void) {
|
||||
toast.success(t("shareManager.expirationUpdateSuccess"));
|
||||
} catch (error) {
|
||||
toast.error(t("shareManager.expirationUpdateError"));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -167,7 +159,6 @@ export function useShareManager(onSuccess: () => void) {
|
||||
setShareToManageFiles(null);
|
||||
} catch (error) {
|
||||
toast.error(t("shareManager.filesUpdateError"));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -179,7 +170,6 @@ export function useShareManager(onSuccess: () => void) {
|
||||
setShareToManageRecipients(null);
|
||||
} catch (error) {
|
||||
toast.error(t("shareManager.recipientsUpdateError"));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -189,7 +179,6 @@ export function useShareManager(onSuccess: () => void) {
|
||||
toast.success(t("shareManager.linkGenerateSuccess"));
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t("shareManager.linkGenerateError"));
|
||||
throw error;
|
||||
}
|
||||
@@ -204,7 +193,6 @@ export function useShareManager(onSuccess: () => void) {
|
||||
toast.dismiss(loadingToast);
|
||||
toast.success(t("shareManager.notifySuccess"));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.dismiss(loadingToast);
|
||||
toast.error(t("shareManager.notifyError"));
|
||||
}
|
||||
|
@@ -6,6 +6,7 @@ import type {
|
||||
LoginBody,
|
||||
LoginResult,
|
||||
LogoutResult,
|
||||
OIDCConfigResult,
|
||||
RequestPasswordResetBody,
|
||||
RequestPasswordResetResult,
|
||||
ResetPasswordBody,
|
||||
@@ -37,3 +38,16 @@ export const resetPassword = <TData = ResetPasswordResult>(
|
||||
export const getCurrentUser = <TData = GetCurrentUserResult>(options?: AxiosRequestConfig): Promise<TData> => {
|
||||
return apiInstance.get(`/api/auth/me`, options);
|
||||
};
|
||||
|
||||
export const getOIDCConfig = <TData = OIDCConfigResult>(options?: AxiosRequestConfig): Promise<TData> => {
|
||||
return apiInstance.get(`/api/auth/oidc/config`, options);
|
||||
};
|
||||
|
||||
export const initiateOIDCLogin = (state?: string, redirectUri?: string): string => {
|
||||
const params = new URLSearchParams();
|
||||
if (state) params.append("state", state);
|
||||
if (redirectUri) params.append("redirect_uri", redirectUri);
|
||||
|
||||
const queryString = params.toString();
|
||||
return `/api/auth/oidc/authorize${queryString ? `?${queryString}` : ""}`;
|
||||
};
|
||||
|
@@ -5,6 +5,7 @@ import type {
|
||||
Login200,
|
||||
LoginBody,
|
||||
Logout200,
|
||||
OidcConfig200,
|
||||
RequestPasswordReset200,
|
||||
RequestPasswordResetBody,
|
||||
ResetPassword200,
|
||||
@@ -18,3 +19,6 @@ export type ResetPasswordResult = AxiosResponse<ResetPassword200>;
|
||||
export type GetCurrentUserResult = AxiosResponse<GetCurrentUser200>;
|
||||
|
||||
export type { LoginBody, RequestPasswordResetBody, ResetPasswordBody };
|
||||
|
||||
export type OIDCConfigResult = AxiosResponse<OidcConfig200>;
|
||||
export type OIDCConfigData = OidcConfig200;
|
||||
|
@@ -147,6 +147,7 @@ export * from "./notifyRecipients400";
|
||||
export * from "./notifyRecipients401";
|
||||
export * from "./notifyRecipients404";
|
||||
export * from "./notifyRecipientsBody";
|
||||
export * from "./oidcConfig200";
|
||||
export * from "./registerFile201";
|
||||
export * from "./registerFile201File";
|
||||
export * from "./registerFile400";
|
||||
|
14
apps/web/src/http/models/oidcConfig200.ts
Normal file
14
apps/web/src/http/models/oidcConfig200.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by orval v7.5.0 🍺
|
||||
* Do not edit manually.
|
||||
* 🌴 Palmr. API
|
||||
* API documentation for Palmr file sharing system
|
||||
* OpenAPI spec version: 1.0.0
|
||||
*/
|
||||
|
||||
export interface OidcConfig200 {
|
||||
enabled: boolean;
|
||||
issuer?: string;
|
||||
authUrl?: string;
|
||||
scopes?: string[];
|
||||
}
|
@@ -16,7 +16,6 @@ export default getRequestConfig(async ({ locale }) => {
|
||||
messages: (await import(`../../messages/${resolvedLocale}.json`)).default,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return {
|
||||
locale: DEFAULT_LOCALE,
|
||||
messages: (await import(`../../messages/${DEFAULT_LOCALE}.json`)).default,
|
||||
|
185
apps/web/src/lib/actions/config.ts
Normal file
185
apps/web/src/lib/actions/config.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const SENSITIVE_KEYS = [
|
||||
"smtpPass",
|
||||
"smtpUser",
|
||||
"oidcClientSecret",
|
||||
"oidcIssuerUrl",
|
||||
"oidcClientId",
|
||||
"oidcRedirectUri",
|
||||
"serverUrl",
|
||||
];
|
||||
|
||||
const BLACKLISTED_KEYS = ["smtpPass", "oidcClientSecret"];
|
||||
|
||||
interface Config {
|
||||
key: string;
|
||||
value: string;
|
||||
type: string;
|
||||
group: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ConfigsResponse {
|
||||
configs: Config[];
|
||||
}
|
||||
|
||||
interface UserResponse {
|
||||
user: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
username: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
isActive: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the current user is an administrator
|
||||
* @returns true se for admin, false caso contrário
|
||||
*/
|
||||
async function validateAdminAccess(): Promise<boolean> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const cookieHeader = cookieStore.toString();
|
||||
|
||||
const apiRes = await fetch(`${process.env.API_BASE_URL}/auth/me`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!apiRes.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data: UserResponse = await apiRes.json();
|
||||
return data.user?.isAdmin === true;
|
||||
} catch (error) {
|
||||
console.error("Error validating admin access:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches secure configurations, filtering sensitive data
|
||||
* This server action replaces the direct call to getAllConfigs
|
||||
*/
|
||||
export async function getSecureConfigs(): Promise<ConfigsResponse> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const cookieHeader = cookieStore.toString();
|
||||
|
||||
const apiRes = await fetch(`${process.env.API_BASE_URL}/app/configs`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!apiRes.ok) {
|
||||
throw new Error(`API request failed with status ${apiRes.status}`);
|
||||
}
|
||||
|
||||
const data: ConfigsResponse = await apiRes.json();
|
||||
|
||||
const filteredConfigs = data.configs
|
||||
.filter((config) => !BLACKLISTED_KEYS.includes(config.key))
|
||||
.map((config) => {
|
||||
if (SENSITIVE_KEYS.includes(config.key)) {
|
||||
return {
|
||||
...config,
|
||||
value: config.value ? "***HIDDEN***" : config.value,
|
||||
};
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
return {
|
||||
configs: filteredConfigs,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching secure configs:", error);
|
||||
throw new Error("Failed to fetch configurations");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches admin configurations, requires admin access
|
||||
*/
|
||||
export async function getAdminConfigs(): Promise<ConfigsResponse> {
|
||||
try {
|
||||
const isAdmin = await validateAdminAccess();
|
||||
if (!isAdmin) {
|
||||
throw new Error("Unauthorized: Admin access required");
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const cookieHeader = cookieStore.toString();
|
||||
|
||||
const apiRes = await fetch(`${process.env.API_BASE_URL}/app/configs`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!apiRes.ok) {
|
||||
throw new Error(`API request failed with status ${apiRes.status}`);
|
||||
}
|
||||
|
||||
const data: ConfigsResponse = await apiRes.json();
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching admin configs:", error);
|
||||
throw new Error("Failed to fetch configurations");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSecureConfigValue(key: string): Promise<string | null> {
|
||||
try {
|
||||
if (BLACKLISTED_KEYS.includes(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const cookieHeader = cookieStore.toString();
|
||||
|
||||
const apiRes = await fetch(`${process.env.API_BASE_URL}/app/configs`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: cookieHeader || "",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!apiRes.ok) {
|
||||
throw new Error(`API request failed with status ${apiRes.status}`);
|
||||
}
|
||||
|
||||
const data: ConfigsResponse = await apiRes.json();
|
||||
const config = data.configs.find((c) => c.key === key);
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (SENSITIVE_KEYS.includes(key)) {
|
||||
return config.value ? "***HIDDEN***" : config.value;
|
||||
}
|
||||
|
||||
return config.value;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching config value for key ${key}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
@@ -20,21 +20,21 @@ export interface ErrorData {
|
||||
* If not found, returns a default object with code "error" and details "undefined".
|
||||
*/
|
||||
const getErrorData = (error: unknown): ErrorData => {
|
||||
// Check if it's an Axios error and has a response with data
|
||||
if (
|
||||
axios.isAxiosError(error) &&
|
||||
error.response?.data &&
|
||||
typeof error.response.data.code === 'string' &&
|
||||
error.response.data.code.length > 0 // Ensure it's not an empty string
|
||||
typeof error.response.data.code === "string" &&
|
||||
error.response.data.code.length > 0
|
||||
) {
|
||||
// Return the code string
|
||||
const code = error.response.data.code;
|
||||
const details = typeof error.response.data.details === 'string' && error.response.data.details !== null ? error.response.data.details : undefined;
|
||||
const details =
|
||||
typeof error.response.data.details === "string" && error.response.data.details !== null
|
||||
? error.response.data.details
|
||||
: undefined;
|
||||
return { code, details };
|
||||
}
|
||||
|
||||
// If code invalid, return "error" as code
|
||||
return { code: "error", details: "undefined" };
|
||||
};
|
||||
|
||||
export default getErrorData;
|
||||
export default getErrorData;
|
||||
|
Reference in New Issue
Block a user