fix: improve oidc,fix truncate

This commit is contained in:
Théo LAGACHE
2026-02-23 16:47:01 +01:00
18 changed files with 201 additions and 191 deletions
+4
View File
@@ -40,6 +40,10 @@ AUTH_OIDC_JWKS_ENDPOINT=""
AUTH_OIDC_PKCE=true AUTH_OIDC_PKCE=true
ALLOWED_GROUP="admin" ALLOWED_GROUP="admin"
# If true, the user's role will be updated from OIDC claims on every login.
# If false, the role is only set when the user is first created.
AUTH_SYNC_OIDC_ROLES_ON_LOGIN=true
AUTH_EMAIL_PASSWORD_ENABLED=true AUTH_EMAIL_PASSWORD_ENABLED=true
AUTH_SIGNUP_ENABLED=true AUTH_SIGNUP_ENABLED=true
AUTH_PASSKEY_ENABLED=true AUTH_PASSKEY_ENABLED=true
+2 -2
View File
@@ -26,5 +26,5 @@ keywords:
- web-ui - web-ui
- agent - agent
license: Apache-2.0 license: Apache-2.0
version: 1.2.8-rc.1 version: 1.2.9
date-released: "2026-02-17" date-released: "2026-02-19"
+2 -2
View File
@@ -33,14 +33,14 @@
<strong> <strong>
<a href="https://portabase.io">Website</a> • <a href="https://portabase.io">Website</a> •
<a href="https://portabase.io/docs">Documentation</a> • <a href="https://portabase.io/docs">Documentation</a> •
<a href="https://www.youtube.com/watch?v=hvLbX5LN1UE">Demo</a> • <a href="https://www.youtube.com/watch?v=nSTzT27GgAg">Demo</a> •
<a href="https://portabase.io/docs/dashboard/setup">Installation</a> • <a href="https://portabase.io/docs/dashboard/setup">Installation</a> •
<a href="https://github.com/Portabase/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> • <a href="https://github.com/Portabase/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> •
<a href="https://github.com/Portabase/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a> <a href="https://github.com/Portabase/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
</strong> </strong>
</p> </p>
![portabase-dashboard](https://github.com/user-attachments/assets/f5ec7204-79c0-43ac-ba17-f2ed682c39d6) ![portabase-dashboard](https://github.com/user-attachments/assets/8f2c69d6-f1f9-4b80-b51c-01f6f13b9b62)
</div> </div>
@@ -1,30 +0,0 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {db} from "@/db";
import {SettingsEmailSection} from "@/components/wrappers/dashboard/admin/settings/email/settings-email-section";
import {notFound} from "next/navigation";
export default async function RoutePage(props: PageParams<{}>) {
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
if (!settings) {
notFound()
}
return (
<Page>
<PageHeader className="flex flex-col">
<div className="flex justify-between">
<PageTitle className="mb-3">System email</PageTitle>
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<SettingsEmailSection settings={settings}/>
</PageContent>
</Page>
);
}
@@ -1,29 +0,0 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {SettingsStorageSection} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage-section";
import {db} from "@/db";
import {notFound} from "next/navigation";
export default async function RoutePage(props: PageParams<{}>) {
const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"),
});
if (!settings) {
notFound()
}
return (
<Page>
<PageHeader className="flex flex-col">
<div className="flex justify-between">
<PageTitle className="mb-3">System storage</PageTitle>
</div>
</PageHeader>
<PageContent className="flex flex-col gap-5">
<SettingsStorageSection settings={settings}/>
</PageContent>
</Page>
);
}
@@ -35,8 +35,6 @@ export default async function RoutePage(
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id); const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
console.log("edgeKey", edgeKey);
return ( return (
<Page> <Page>
<div className="justify-between gap-2 sm:flex"> <div className="justify-between gap-2 sm:flex">
+1
View File
@@ -30,6 +30,7 @@ services:
- "localhost:host-gateway" - "localhost:host-gateway"
volumes: volumes:
- ./private/uploads/tmp:/data/uploads/tmp - ./private/uploads/tmp:/data/uploads/tmp
user: "1000:1000"
keycloak: keycloak:
image: quay.io/keycloak/keycloak:latest image: quay.io/keycloak/keycloak:latest
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "portabase", "name": "portabase",
"version": "1.2.8-rc.1", "version": "1.2.9",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 8887", "dev": "next dev --turbopack -p 8887",
+2
View File
@@ -208,12 +208,14 @@ type UseZodFormProps<Z extends ZodSchema> = Exclude<
schema: Z; schema: Z;
}; };
const useZodForm = <Z extends ZodSchema>({ const useZodForm = <Z extends ZodSchema>({
schema, schema,
...formProps ...formProps
}: UseZodFormProps<Z>) => }: UseZodFormProps<Z>) =>
useForm({ useForm({
...formProps, ...formProps,
// @ts-ignore
resolver: zodResolver(schema), resolver: zodResolver(schema),
}); });
@@ -51,7 +51,8 @@ export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestC
toast.success("Successfully connected to storage channel"); toast.success("Successfully connected to storage channel");
} else { } else {
console.error(result); console.error(result);
toast.error("An error occurred while testing the storage channel, check your configuration"); const responseText = result.response ? `(${result.response})` : "";
toast.error(`An error occurred while testing the storage channel, check your configuration ${responseText}`);
} }
} else { } else {
toast.error("Not yet supported"); toast.error("Not yet supported");
@@ -4,7 +4,15 @@ import {Info} from "lucide-react";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {Setting} from "@/db/schema/01_setting"; import {Setting} from "@/db/schema/01_setting";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
useZodForm
} from "@/components/ui/form";
import {StorageChannelWith} from "@/db/schema/12_storage-channel"; import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {useMutation} from "@tanstack/react-query"; import {useMutation} from "@tanstack/react-query";
@@ -18,6 +26,7 @@ import {
} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.action"; } from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.action";
import {toast} from "sonner"; import {toast} from "sonner";
import {Switch} from "@/components/ui/switch"; import {Switch} from "@/components/ui/switch";
import {downloadMasterKeyAction} from "@/features/keys/keys.action";
export type SettingsStorageSectionProps = { export type SettingsStorageSectionProps = {
settings: Setting; settings: Setting;
@@ -50,6 +59,34 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
} }
}); });
const handleDownload = async () => {
const result = await downloadMasterKeyAction();
if (!result?.success) {
toast.error(result?.message ?? "Download failed");
return;
}
const byteCharacters = atob(result.data as string);
const byteNumbers = new Array(byteCharacters.length)
.fill(null)
.map((_, i) => byteCharacters.charCodeAt(i));
const blob = new Blob([new Uint8Array(byteNumbers)], {
type: "application/octet-stream",
});
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "master_key.bin";
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
};
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<Alert className="mt-3"> <Alert className="mt-3">
@@ -59,7 +96,7 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
The default storage channel will be used by default to store your backups if no storage policy is The default storage channel will be used by default to store your backups if no storage policy is
configured at the database level. </AlertDescription> configured at the database level. </AlertDescription>
</Alert> </Alert>
<div className="flex flex-col h-full py-4 "> <div className="flex flex-col h-full py-4 gap-3">
<Form <Form
className="space-y-4" className="space-y-4"
form={form} form={form}
@@ -96,8 +133,6 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="encryption" name="encryption"
@@ -120,6 +155,18 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
Confirm Confirm
</ButtonWithLoading> </ButtonWithLoading>
</Form> </Form>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium ">
Download instance <button
type="button"
onClick={handleDownload}
className="text-sm hover:text-muted-foreground underline underline-offset-4 transition-colors w-fit"
>
Master Key
</button>
</span>
</div>
</div> </div>
</div> </div>
); );
@@ -1,14 +1,6 @@
"use client"; "use client";
import { Backup, BackupWith, Restoration } from "@/db/schema/07_database"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Swiper, SwiperSlide } from "swiper/react"; import { Badge } from "@/components/ui/badge";
//@ts-ignore
import "swiper/css";
import "swiper/css/pagination";
import { Pagination, Mousewheel } from "swiper/modules";
import {
DatabaseActionKind,
useBackupModal,
} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import { import {
Form, Form,
FormControl, FormControl,
@@ -18,33 +10,36 @@ import {
FormMessage, FormMessage,
useZodForm, useZodForm,
} from "@/components/ui/form"; } from "@/components/ui/form";
import {
BackupActionsSchema,
BackupActionsType,
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.schema";
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { BackupStorageWith } from "@/db/schema/14_storage-backup";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import { getChannelIcon } from "@/components/wrappers/dashboard/admin/channels/helpers/common"; import { getChannelIcon } from "@/components/wrappers/dashboard/admin/channels/helpers/common";
import { Badge } from "@/components/ui/badge";
import { import {
getStatusColor, getStatusColor,
getStatusIcon, getStatusIcon,
} from "@/components/wrappers/dashboard/admin/notifications/logs/columns"; } from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
import { useRouter } from "next/navigation";
import { import {
createRestorationBackupAction, createRestorationBackupAction,
deleteBackupAction, deleteBackupAction,
deleteBackupStorageAction, deleteBackupStorageAction,
downloadBackupAction, downloadBackupAction,
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action"; } from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action";
import { toast } from "sonner"; import {
import { SafeActionResult } from "next-safe-action"; BackupActionsSchema,
BackupActionsType,
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.schema";
import {
DatabaseActionKind,
useBackupModal,
} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
import { Backup, BackupWith, Restoration } from "@/db/schema/07_database";
import { BackupStorageWith } from "@/db/schema/14_storage-backup";
import { ServerActionResult } from "@/types/action-type"; import { ServerActionResult } from "@/types/action-type";
import { ZodString } from "zod"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertCircleIcon } from "lucide-react"; import { AlertCircleIcon } from "lucide-react";
import { SafeActionResult } from "next-safe-action";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { ZodString } from "zod";
type BackupActionsFormProps = { type BackupActionsFormProps = {
backup: BackupWith; backup: BackupWith;
@@ -161,7 +156,7 @@ export const BackupActionsForm = ({
<TooltipProvider> <TooltipProvider>
<Form <Form
form={form} form={form}
className="flex flex-col gap-4 mb-1" className="flex flex-col gap-4 mb-1 min-w-0"
onSubmit={async (values) => { onSubmit={async (values) => {
await mutation.mutateAsync(values); await mutation.mutateAsync(values);
}} }}
@@ -174,94 +169,82 @@ export const BackupActionsForm = ({
<FormItem> <FormItem>
<FormLabel>Choose a storage backup</FormLabel> <FormLabel>Choose a storage backup</FormLabel>
<FormControl> <FormControl>
<div style={{ height: "250px" }}> <div
<Swiper className="flex flex-col gap-2.5 overflow-y-auto overflow-x-hidden w-full pr-1"
direction="vertical" style={{ maxHeight: "250px" }}
slidesPerView={3.5} >
spaceBetween={10} {filteredBackupStorages.map(
// pagination={{ clickable: true }} (storage: BackupStorageWith) => (
mousewheel={{ releaseOnEdges: true, forceToAxis: true }} <button
modules={[Pagination, Mousewheel]} key={storage.id}
className="mySwiper" disabled={
style={{ height: "100%" }} action !== "delete" &&
> storage.status.toLowerCase() !== "success"
{filteredBackupStorages.map( }
(storage: BackupStorageWith) => ( type="button"
<SwiperSlide key={storage.id}> onClick={() => field.onChange(storage.id)}
<button className={`w-full flex items-center gap-3 p-4 rounded-lg border text-left transition-colors
disabled={ ${
action !== "delete" && field.value === storage.id
storage.status.toLowerCase() !== "success" ? "border-foreground bg-background"
} : "border-border bg-background" +
type="button" (storage.status.toLowerCase() === "success" ||
onClick={() => field.onChange(storage.id)} action === "delete"
className={`w-full h-full flex items-start gap-3 p-4 rounded-lg border text-left transition-colors ? " hover:border-muted-foreground"
${ : "")
field.value === }
storage.id ${
? "border-foreground bg-background" storage.status.toLowerCase() !== "success" &&
: "border-border bg-background" + action !== "delete"
(storage.status.toLowerCase() === ? "opacity-50 cursor-not-allowed"
"success" || : ""
action === }`}
"delete" >
? " hover:border-muted-foreground" <div
: "") className={`h-4 w-4 shrink-0 rounded-full border ${
} field.value === storage.id
${storage.status.toLowerCase() !== "success" && action !== "delete" ? "opacity-50 cursor-not-allowed" : ""}`} ? "border-foreground"
> : "border-muted-foreground"
<div } flex items-center justify-center`}
className={`mt-0.5 h-4 w-4 shrink-0 rounded-full border ${ >
field.value === storage.id {field.value === storage.id && (
? "border-foreground" <div className="h-2 w-2 rounded-full bg-foreground" />
: "border-muted-foreground" )}
} flex items-center justify-center`} </div>
<div className="flex-1 min-w-0 flex items-center gap-2">
<div className="shrink-0">
{getChannelIcon(
storage.storageChannel?.provider || "",
)}
</div>
<h3 className="font-medium text-foreground truncate flex-1">
{storage.storageChannel?.name}
</h3>
{storage.storageChannel?.provider && (
<Badge
variant="secondary"
className="text-xs font-mono shrink-0"
> >
{field.value === storage.id && ( {storage.storageChannel.provider}
<div className="h-2 w-2 rounded-full bg-foreground" /> </Badge>
)} )}
</div>
<div className="flex-1 min-w-0"> <Badge
<div className="flex items-center gap-2"> variant="outline"
<div className="flex-1 min-w-0 flex flex-col gap-1"> className={`gap-1.5 shrink-0 ${getStatusColor(storage.status)}`}
<div className="flex items-center justify-between gap-2 min-w-0 w-full"> >
<div className="flex items-center gap-2 min-w-0 flex-1"> {getStatusIcon(storage.status === "success")}
<div className="shrink-0"> <span className="capitalize hidden sm:inline">
{getChannelIcon( {storage.status.toUpperCase()}
storage.storageChannel?.provider || </span>
"", </Badge>
)} </div>
</div> </button>
<h3 className="font-medium text-foreground truncate min-w-0"> ),
{storage.storageChannel?.name} ) ?? <p>No storages available</p>}
</h3>
<Badge
variant="secondary"
className="text-xs font-mono shrink-0"
>
{storage.storageChannel?.provider}
</Badge>
</div>
<Badge
variant="outline"
className={`gap-1.5 shrink-0 ${getStatusColor(storage.status)}`}
>
{getStatusIcon(
storage.status === "success",
)}
<span className="capitalize">
{storage.status.toUpperCase()}
</span>
</Badge>
</div>
</div>
</div>
</div>
</button>
</SwiperSlide>
),
) ?? <p>No storages available</p>}
</Swiper>
</div> </div>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
+4
View File
@@ -58,6 +58,8 @@ export const env = createEnv({
AUTH_SIGNUP_ENABLED: z.string().optional().default("true"), AUTH_SIGNUP_ENABLED: z.string().optional().default("true"),
AUTH_PASSKEY_ENABLED: z.string().optional().default("true"), AUTH_PASSKEY_ENABLED: z.string().optional().default("true"),
AUTH_SYNC_OIDC_ROLES_ON_LOGIN: z.enum(["true", "false"]).default("true"),
PRIVATE_PATH: z.string().optional(), PRIVATE_PATH: z.string().optional(),
}, },
client: { client: {
@@ -107,6 +109,8 @@ export const env = createEnv({
AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED, AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED,
AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED, AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED,
AUTH_SYNC_OIDC_ROLES_ON_LOGIN: process.env.AUTH_SYNC_OIDC_ROLES_ON_LOGIN,
PRIVATE_PATH: PRIVATE_PATH:
process.env.PRIVATE_PATH || path.join(process.cwd(), "private"), process.env.PRIVATE_PATH || path.join(process.cwd(), "private"),
}, },
+21 -2
View File
@@ -1,3 +1,4 @@
"use server"
import fs from "node:fs"; import fs from "node:fs";
import {env} from "@/env.mjs"; import {env} from "@/env.mjs";
import path from "path"; import path from "path";
@@ -6,7 +7,7 @@ import path from "path";
/** /**
* Get Public server key content * Get Public server key content
*/ */
export function getPublicServerKeyContent() { export async function getPublicServerKeyContent() {
try { try {
const keyPath = path.join(env.PRIVATE_PATH, '/keys/server_public.pem') const keyPath = path.join(env.PRIVATE_PATH, '/keys/server_public.pem')
return fs.readFileSync(keyPath, "utf8"); return fs.readFileSync(keyPath, "utf8");
@@ -23,7 +24,7 @@ export function getPublicServerKeyContent() {
/** /**
* Get Master server key * Get Master server key
*/ */
export function getMasterServerKeyContent() { export async function getMasterServerKeyContent() {
try { try {
const keyPath = path.join(env.PRIVATE_PATH, '/keys/master_key.bin') const keyPath = path.join(env.PRIVATE_PATH, '/keys/master_key.bin')
return fs.readFileSync(keyPath); return fs.readFileSync(keyPath);
@@ -35,3 +36,21 @@ export function getMasterServerKeyContent() {
}; };
} }
} }
export async function downloadMasterKeyAction() {
try {
const keyPath = path.join(env.PRIVATE_PATH, "keys/master_key.bin");
const fileBuffer = fs.readFileSync(keyPath);
return {
success: true,
data: fileBuffer.toString("base64"),
};
} catch (error) {
return {
success: false,
message: "Unable to download master key",
};
}
}
+3 -1
View File
@@ -97,12 +97,14 @@ export async function dispatchStorage(
} }
return await dispatchViaProvider( const dispatchResult = await dispatchViaProvider(
channel.provider as StorageProviderKind, channel.provider as StorageProviderKind,
channel.config, channel.config,
input, input,
); );
console.log(dispatchResult);
return dispatchResult;
} catch (err: any) { } catch (err: any) {
return { return {
+1 -1
View File
@@ -18,7 +18,7 @@ async function getS3Client(config: S3Config) {
region: config.region ?? "us-east-1", region: config.region ?? "us-east-1",
accessKey: config.accessKey, accessKey: config.accessKey,
secretKey: config.secretKey, secretKey: config.secretKey,
port: config.port ?? 443, port: config.port ? Number(config.port) : 443,
useSSL: config.ssl ?? true, useSSL: config.ssl ?? true,
}); });
} }
+12 -4
View File
@@ -161,6 +161,18 @@ export const auth = betterAuth({
issuer: p.issuerUrl, issuer: p.issuerUrl,
})), })),
provisionUser: async ({ user: usr, userInfo, provider }) => { provisionUser: async ({ user: usr, userInfo, provider }) => {
const existingUser = await db.query.user.findFirst({
where: eq(drizzleDb.schemas.user.email, usr.email),
});
if (existingUser && existingUser.role === "superadmin") {
return;
}
if (existingUser && env.AUTH_SYNC_OIDC_ROLES_ON_LOGIN === "false") {
return;
}
const providerId = provider.providerId; const providerId = provider.providerId;
const oidcProvider = oidcProviders.find((p) => p.id === providerId); const oidcProvider = oidcProviders.find((p) => p.id === providerId);
const allowedGroup = oidcProvider?.allowedGroup || env.ALLOWED_GROUP; const allowedGroup = oidcProvider?.allowedGroup || env.ALLOWED_GROUP;
@@ -225,10 +237,6 @@ export const auth = betterAuth({
roleToAssign = "superadmin"; roleToAssign = "superadmin";
} }
const existingUser = await db.query.user.findFirst({
where: eq(drizzleDb.schemas.user.email, usr.email),
});
if (existingUser) { if (existingUser) {
await db await db
.update(drizzleDb.schemas.user) .update(drizzleDb.schemas.user)
+1 -1
View File
@@ -2,7 +2,7 @@
import {getMasterServerKeyContent} from "@/features/keys/keys.action"; import {getMasterServerKeyContent} from "@/features/keys/keys.action";
export async function generateEdgeKey(serverUrl: string, agentId: string): Promise<string> { export async function generateEdgeKey(serverUrl: string, agentId: string): Promise<string> {
const masterKey = getMasterServerKeyContent() const masterKey = await getMasterServerKeyContent()
const edgeKeyData = { const edgeKeyData = {
serverUrl, serverUrl,
agentId, agentId,