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:
Daniel Luiz Alves
2025-06-03 16:15:22 -03:00
parent 459783b152
commit 998b690659
93 changed files with 3328 additions and 454 deletions

View File

@@ -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"
},

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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,
});

View File

@@ -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);

View File

@@ -1,5 +1,3 @@
import { env } from "../env";
/**
* Timeout Configuration for Large File Handling
*

View File

@@ -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" });
}

View File

@@ -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(),
},
});

View File

@@ -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",

View File

@@ -16,7 +16,7 @@ export class AppService {
appName,
appDescription,
appLogo,
firstUserAccess : firstUserAccess === "true",
firstUserAccess: firstUserAccess === "true",
};
}

View 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);
}
}

View File

@@ -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) {

View File

@@ -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;
}

View 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);
}
}
}

View 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>;

View 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));
}

View 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);
}
}
}

View File

@@ -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);
}

View File

@@ -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`);

View File

@@ -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": "فشل في المصادقة. حاول مرة أخرى."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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": "प्रमाणीकरण विफल। कृपया पुनः प्रयास करें।"
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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": "認証に失敗しました。もう一度お試しください。"
}
}
}

View File

@@ -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": "인증에 실패했습니다. 다시 시도하세요."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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",

View File

@@ -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:

View File

@@ -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";

View File

@@ -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,

View File

@@ -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"));
}
};

View File

@@ -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,

View 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;
}

View 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;
}

View 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;
}

View File

@@ -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",
},
}
);
}

View File

@@ -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");

View 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>
);
}

View 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>
);
}

View File

@@ -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",
};
}

View File

@@ -30,7 +30,6 @@ export function useFiles() {
setFiles(sortedFiles);
} catch (error) {
toast.error(t("files.loadError"));
console.error(error);
} finally {
setIsLoading(false);
}

View File

@@ -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>

View File

@@ -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")}

View File

@@ -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 />
</>
);
}

View 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>
</>
);
}

View File

@@ -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 {

View 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,
};
}

View File

@@ -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);
}
};

View File

@@ -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;

View File

@@ -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);
}

View 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";

View File

@@ -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,

View File

@@ -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

View File

@@ -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>
);
}
}

View File

@@ -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"),

View File

@@ -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,
};
}

View File

@@ -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">

View File

@@ -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[]>;

View File

@@ -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);
}
};

View File

@@ -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();
}
};

View File

@@ -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"];

View File

@@ -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"));
}

View File

@@ -54,7 +54,6 @@ export function CreateShareModal({ isOpen, onClose, onSuccess }: CreateShareModa
});
} catch (error) {
toast.error(t("createShare.error"));
console.error(error);
} finally {
setIsLoading(false);
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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");
}
};

View File

@@ -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} />

View File

@@ -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);

View File

@@ -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);

View File

@@ -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>

View File

@@ -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) => (

View File

@@ -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);

View File

@@ -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 },

View 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 };

View 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>
);
}

View File

@@ -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>;
}

View File

@@ -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();
}

View 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,
};
}

View File

@@ -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"));
}

View File

@@ -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}` : ""}`;
};

View File

@@ -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;

View File

@@ -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";

View 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[];
}

View File

@@ -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,

View 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;
}
}

View File

@@ -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;