mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcdce63e15 | ||
|
|
c2eaed757d | ||
|
|
920743bea7 | ||
|
|
0970f5baae | ||
|
|
291aa13e9a | ||
|
|
e644cde514 | ||
|
|
2350e68f4f | ||
|
|
cce3b681e3 | ||
|
|
755799f19d | ||
|
|
ec02dc8579 | ||
|
|
7028982ba1 | ||
|
|
f04503e38e | ||
|
|
f1edffed17 | ||
|
|
fac87acee7 | ||
|
|
c46dd91ede | ||
|
|
648753f8cf | ||
|
|
76b3bda313 | ||
|
|
8102dd1a8f | ||
|
|
aceb4e7f4c | ||
|
|
4224389e96 | ||
|
|
7c66c10b6d | ||
|
|
bf06a2e50c | ||
|
|
8c69fdef95 | ||
|
|
883c1d2f57 | ||
|
|
e7f1652b4e | ||
|
|
ea07224a52 | ||
|
|
b655154182 | ||
|
|
a173892163 | ||
|
|
14f48c4a35 | ||
|
|
88aa4daf74 | ||
|
|
25326dc9be | ||
|
|
030cd13f7c | ||
|
|
d557620c81 | ||
|
|
3273f8dd11 | ||
|
|
646719557c | ||
|
|
1640b326be | ||
|
|
cc19c9ec19 | ||
|
|
bb84ab9697 | ||
|
|
0f7664201a | ||
|
|
0b8a8213e6 | ||
|
|
476b3da66b | ||
|
|
41c632ccfa | ||
|
|
dbf5addf12 | ||
|
|
8d414080a4 | ||
|
|
c8d390726c | ||
|
|
31285dd0d6 | ||
|
|
b9ef58044e | ||
|
|
e320f74dfc | ||
|
|
b9deb3cdf0 | ||
|
|
5dbf293e1e | ||
|
|
47f2308acc | ||
|
|
496be692e7 | ||
|
|
5df6dd169a | ||
|
|
7232f17cb7 | ||
|
|
5b1c3d0606 | ||
|
|
8e6ca64fa2 | ||
|
|
9116b4d126 | ||
|
|
737834310b | ||
|
|
4474c8ee5e | ||
|
|
3bf731055f |
+38
-8
@@ -1,10 +1,10 @@
|
||||
# Environnement
|
||||
# Environment
|
||||
NODE_ENV=development
|
||||
|
||||
# Base de données
|
||||
# Database
|
||||
DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
|
||||
|
||||
# Projet
|
||||
# Project
|
||||
PROJECT_NAME="Portabase"
|
||||
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
|
||||
PROJECT_URL=http://localhost:8887
|
||||
@@ -21,11 +21,41 @@ SMTP_FROM=
|
||||
#Port 465 -> secure: true
|
||||
#By default : true
|
||||
|
||||
# Google
|
||||
AUTH_GOOGLE_ID=
|
||||
AUTH_GOOGLE_SECRET=
|
||||
AUTH_GOOGLE_METHOD=
|
||||
# OIDC SSO Authentification
|
||||
AUTH_OIDC_ID=""
|
||||
AUTH_OIDC_TITLE=""
|
||||
AUTH_OIDC_DESC=""
|
||||
AUTH_OIDC_ICON=""
|
||||
AUTH_OIDC_CLIENT=""
|
||||
AUTH_OIDC_SECRET=""
|
||||
AUTH_OIDC_ISSUER_URL=""
|
||||
AUTH_OIDC_HOST=""
|
||||
AUTH_OIDC_SCOPES=""
|
||||
AUTH_OIDC_DISCOVERY_ENDPOINT=""
|
||||
AUTH_OIDC_JWKS_ENDPOINT=""
|
||||
AUTH_OIDC_PKCE=true
|
||||
ALLOWED_GROUP="admin"
|
||||
AUTH_OIDC_ROLE_MAP=""
|
||||
|
||||
AUTH_ALLOW_LINKING=true
|
||||
AUTH_ALLOW_UNLINKING=true
|
||||
|
||||
|
||||
# Social OAuth2 Authentification
|
||||
AUTH_SOCIAL_ID=""
|
||||
AUTH_SOCIAL_TITLE=""
|
||||
AUTH_SOCIAL_DESC=""
|
||||
AUTH_SOCIAL_ICON=""
|
||||
AUTH_SOCIAL_CLIENT=""
|
||||
AUTH_SOCIAL_SECRET=""
|
||||
AUTH_SOCIAL_APPLE_APP_BUNDLE_IDENTIFIER=""
|
||||
|
||||
# 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_SIGNUP_ENABLED=true
|
||||
AUTH_PASSKEY_ENABLED=true
|
||||
|
||||
# Retention
|
||||
RETENTION_CRON="* * * * *"
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
sca-deps: # Dependency & container scan
|
||||
sca-deps:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -17,15 +17,16 @@ jobs:
|
||||
ignore-unfixed: true
|
||||
|
||||
|
||||
secrets-gitleaks: # Secrets exposure
|
||||
secrets-gitleaks:
|
||||
if: github.event.pull_request.head.repo.fork == false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Fetch full history for gitleaks
|
||||
fetch-depth: 0
|
||||
- uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
|
||||
with:
|
||||
config-path: .gitleaks.toml # Optional, if you have a custom config
|
||||
config-path: .gitleaks.toml
|
||||
+3
-1
@@ -44,4 +44,6 @@ private/keys/*
|
||||
|
||||
/.env
|
||||
|
||||
certificates
|
||||
seeds/pocket-id/*.db
|
||||
|
||||
certificates
|
||||
|
||||
+2
-2
@@ -26,5 +26,5 @@ keywords:
|
||||
- web-ui
|
||||
- agent
|
||||
license: Apache-2.0
|
||||
version: 1.2.8
|
||||
date-released: "2026-02-19"
|
||||
version: 1.4.3
|
||||
date-released: "2026-03-02"
|
||||
|
||||
@@ -1,4 +1,48 @@
|
||||
CLUSTER_SCRIPT=docker/entrypoints/app-dev-entrypoint.sh
|
||||
|
||||
up:
|
||||
@bash $(CLUSTER_SCRIPT)
|
||||
@bash $(CLUSTER_SCRIPT)
|
||||
|
||||
seed-keycloak:
|
||||
@[ $$(ls -1 seeds/keycloak/*.json 2>/dev/null | wc -l) -gt 0 ] || (echo "No realm export found in seeds/keycloak. Add an export from Keycloak first."; exit 1)
|
||||
@docker compose -f docker-compose.func.yml stop keycloak >/dev/null 2>&1 || true
|
||||
@docker compose -f docker-compose.func.yml rm -f -s keycloak >/dev/null 2>&1 || true
|
||||
@docker volume rm portabase-dev-func_keycloak-data >/dev/null 2>&1 || true
|
||||
@docker compose -f docker-compose.func.yml up -d keycloak
|
||||
@echo "Keycloak seed import triggered from seeds/keycloak/*.json"
|
||||
|
||||
seed-pocket:
|
||||
@[ -f seeds/pocket-id/portabase.zip ] || (echo "No export found in seeds/pocket-id. Add an export from Pocket ID first."; exit 1)
|
||||
@docker compose -f docker-compose.func.yml stop pocket-id >/dev/null 2>&1 || true
|
||||
@docker compose -f docker-compose.func.yml rm -f -s pocket-id >/dev/null 2>&1 || true
|
||||
@docker volume rm portabase-dev-func_pocket-id-data >/dev/null 2>&1 || true
|
||||
@docker compose -f docker-compose.func.yml run --rm -v ./seeds/pocket-id/portabase.zip:/tmp/portabase.zip pocket-id ./pocket-id import --yes --path /tmp/portabase.zip >/dev/null
|
||||
@docker compose -f docker-compose.func.yml up -d pocket-id
|
||||
@sleep 2
|
||||
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin
|
||||
@echo "Pocket ID data restored from seeds/pocket-id/portabase.zip"
|
||||
|
||||
seed-auth: seed-keycloak seed-pocket
|
||||
|
||||
pocket-token:
|
||||
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin
|
||||
|
||||
export-pocket:
|
||||
@echo "Exporting Pocket ID data to seeds/pocket-id/portabase.zip..."
|
||||
@mkdir -p ./seeds/pocket-id
|
||||
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id export --path /tmp/portabase-export.zip
|
||||
@docker compose -f docker-compose.func.yml cp pocket-id:/tmp/portabase-export.zip ./seeds/pocket-id/portabase.zip
|
||||
@docker compose -f docker-compose.func.yml exec pocket-id rm /tmp/portabase-export.zip
|
||||
@echo "Pocket ID data exported to seeds/pocket-id/portabase.zip"
|
||||
|
||||
export-keycloak:
|
||||
@echo "Exporting Keycloak configuration and users to seeds/keycloak/*.json..."
|
||||
@mkdir -p ./seeds/keycloak
|
||||
@rm -f ./seeds/keycloak/*.json
|
||||
@docker compose -f docker-compose.func.yml stop keycloak >/dev/null 2>&1
|
||||
@docker rm -f kc-exporter >/dev/null 2>&1 || true
|
||||
@docker compose -f docker-compose.func.yml run --name kc-exporter keycloak export --dir /tmp/kc-export --users realm_file
|
||||
@docker cp kc-exporter:/tmp/kc-export/. ./seeds/keycloak/
|
||||
@docker rm -f kc-exporter >/dev/null 2>&1
|
||||
@docker compose -f docker-compose.func.yml start keycloak >/dev/null 2>&1
|
||||
@echo "Keycloak configuration and users exported to seeds/keycloak/*.json"
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://www.mysql.com/)
|
||||
[](https://mariadb.org/)
|
||||
[](https://sqlite.org/)
|
||||
[](https://www.mongodb.com/)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
@@ -87,4 +88,3 @@ Distributed under the Apache License. See `LICENSE.txt` for more details.
|
||||
[ShadcnUI-url]: https://ui.shadcn.com/
|
||||
|
||||
[Docker-url]: https://www.docker.com/
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
import { CardContent, CardHeader } from "@/components/ui/card";
|
||||
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {ForgotPasswordForm} from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ForgotPasswordForm } from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form";
|
||||
import { env } from "@/env.mjs";
|
||||
import { CardAuth } from "@/features/layout/card-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) {
|
||||
if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
@@ -12,12 +17,11 @@ export default async function RoutePage(props: { searchParams: Promise<{ callbac
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Reset password</h1>
|
||||
<p className="text-balance text-muted-foreground">Enter your email address and we'll send you a
|
||||
link to reset your password.</p>
|
||||
<p className="text-balance text-muted-foreground">Enter your email address and we'll send you a link to reset your password.</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ForgotPasswordForm/>
|
||||
<ForgotPasswordForm />
|
||||
</CardContent>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
|
||||
+34
-28
@@ -1,32 +1,38 @@
|
||||
import React from "react";
|
||||
import {redirect} from "next/navigation";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {AuthLogoSection} from "@/components/wrappers/auth/auth-logo-section";
|
||||
import {env} from "@/env.mjs";
|
||||
import {Heart} from "lucide-react";
|
||||
import React, { Suspense } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { AuthLogoSection } from "@/components/wrappers/auth/auth-logo-section";
|
||||
import { env } from "@/env.mjs";
|
||||
import { Heart } from "lucide-react";
|
||||
import { ErrorLayout } from "@/components/wrappers/common/error-layout";
|
||||
|
||||
export default async function Layout({children}: { children: React.ReactNode }) {
|
||||
export default async function Layout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const user = await currentUser();
|
||||
|
||||
const user = await currentUser();
|
||||
if (user && !user.banned && user.role !== "pending") {
|
||||
redirect("/dashboard/home");
|
||||
}
|
||||
|
||||
if (user && !user.banned && user.role !== "pending") {
|
||||
redirect("/dashboard/home");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col justify-between py-10 sm:px-6 lg:px-8 ">
|
||||
<div className="flex flex-col items-center justify-center flex-1">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<AuthLogoSection/>
|
||||
<div className="mt-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="mt-8 text-center text-xs text-muted-foreground flex flex-col gap-1">
|
||||
<p className="flex items-center justify-center gap-1">
|
||||
Made with <Heart className="size-3 fill-red-500 text-red-500" /> by <span className="font-medium text-foreground">Portabase</span>
|
||||
</p>
|
||||
<p>v{env.NEXT_PUBLIC_PROJECT_VERSION}</p>
|
||||
</footer>
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col justify-between py-10 sm:px-6 lg:px-8 ">
|
||||
<div className="flex flex-col items-center justify-center flex-1">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<AuthLogoSection />
|
||||
<div className="mt-4">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<footer className="mt-8 text-center text-xs text-muted-foreground flex flex-col gap-1">
|
||||
<p className="flex items-center justify-center gap-1">
|
||||
Made with{" "}
|
||||
<Heart className="size-3 fill-red-500 text-red-500 animate-pulse" />{" "}
|
||||
by <span className="font-medium text-foreground">Portabase</span>
|
||||
</p>
|
||||
<p>v{env.NEXT_PUBLIC_PROJECT_VERSION}</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+50
-42
@@ -1,52 +1,60 @@
|
||||
import {LoginForm} from "@/components/wrappers/auth/login/login-form/login-form";
|
||||
import {Metadata} from "next";
|
||||
import {SUPPORTED_PROVIDERS} from "../../../portabase.config";
|
||||
import {SocialAuthButtons} from "@/components/wrappers/auth/social-buttons";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
import { LoginForm } from "@/components/wrappers/auth/login/login-form/login-form";
|
||||
import { Metadata } from "next";
|
||||
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
|
||||
import { SocialAuthButtons } from "@/components/wrappers/auth/social-buttons";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { CardContent, CardHeader } from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { CardAuth } from "@/features/layout/card-auth";
|
||||
import { env } from "@/env.mjs";
|
||||
import { en } from "zod/v4/locales";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Login",
|
||||
title: "Login",
|
||||
};
|
||||
|
||||
export default async function SignInPage() {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<CardAuth className="w-full">
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Login</h1>
|
||||
<p className="text-balance text-muted-foreground">
|
||||
Fill your login informations
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{env.AUTH_EMAIL_PASSWORD_ENABLED === "true" && (
|
||||
<LoginForm isPasskeyEnabled={env.AUTH_PASSKEY_ENABLED === "true"} />
|
||||
)}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<CardAuth className="w-full">
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Login</h1>
|
||||
<p className="text-balance text-muted-foreground">Fill your login informations</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<LoginForm/>
|
||||
{env.AUTH_EMAIL_PASSWORD_ENABLED === "true" &&
|
||||
SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive)
|
||||
.length > 0 && (
|
||||
<div className="relative my-4 flex items-center justify-center overflow-hidden">
|
||||
<Separator />
|
||||
<div className="px-2 text-center text-sm">OR</div>
|
||||
<Separator />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length > 0 && (
|
||||
<>
|
||||
<div className="relative my-4 flex items-center justify-center overflow-hidden">
|
||||
<Separator/>
|
||||
<div className="px-2 text-center text-sm">OR</div>
|
||||
<Separator/>
|
||||
</div>
|
||||
<SocialAuthButtons providers={SUPPORTED_PROVIDERS}/>
|
||||
</>
|
||||
)}
|
||||
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length >
|
||||
0 && <SocialAuthButtons providers={SUPPORTED_PROVIDERS} />}
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Don't have an account ?{" "}
|
||||
<Link href="/register" className="underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
|
||||
|
||||
)
|
||||
{env.AUTH_SIGNUP_ENABLED !== "true" ||
|
||||
(env.AUTH_EMAIL_PASSWORD_ENABLED === "true" && (
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Don't have an account ?{" "}
|
||||
<Link href="/register" className="underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {RegisterForm} from "@/components/wrappers/auth/register/register-form/register-form";
|
||||
import {Metadata} from "next";
|
||||
import { PageParams } from "@/types/next";
|
||||
import { RegisterForm } from "@/components/wrappers/auth/register/register-form/register-form";
|
||||
import { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Register",
|
||||
title: "Register",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
return (
|
||||
<div className="mx-auto grid w-full gap-6">
|
||||
<RegisterForm/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (
|
||||
env.AUTH_SIGNUP_ENABLED !== "true" ||
|
||||
env.AUTH_EMAIL_PASSWORD_ENABLED !== "true"
|
||||
) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid w-full gap-6">
|
||||
<RegisterForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@ import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-passwo
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
import { CardAuth } from "@/features/layout/card-auth";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
|
||||
if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") {
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
const { token } = await props.searchParams;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {db} from "@/db";
|
||||
import {desc, isNull} from "drizzle-orm";
|
||||
import {AdminUserList} from "@/components/wrappers/dashboard/admin/users/admin-user-list";
|
||||
import {AdminUserAddModal} from "@/components/wrappers/dashboard/admin/users/admin-user-add-modal";
|
||||
import {SUPPORTED_PROVIDERS} from "@/lib/auth/config";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
@@ -21,6 +22,9 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
},
|
||||
});
|
||||
|
||||
const credentialProvider = SUPPORTED_PROVIDERS.find(p => p.id === 'credential');
|
||||
const isPasswordAuthEnabled = credentialProvider?.isActive || false;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader className="flex flex-col">
|
||||
@@ -32,7 +36,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-5">
|
||||
<AdminUserList users={users}/>
|
||||
<AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -1,61 +1,69 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import { PageParams } from "@/types/next";
|
||||
import {
|
||||
Page,
|
||||
PageContent,
|
||||
PageDescription,
|
||||
PageTitle,
|
||||
} from "@/features/layout/page";
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {notFound} from "next/navigation";
|
||||
import {ButtonDeleteAgent} from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {generateEdgeKey} from "@/utils/edge_key";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-content";
|
||||
import {AgentDialog} from "@/features/agents/components/agent.dialog";
|
||||
import {AgentType} from "@/features/agents/agents.schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ButtonDeleteAgent } from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
|
||||
import { capitalizeFirstLetter } from "@/utils/text";
|
||||
import { generateEdgeKey } from "@/utils/edge_key";
|
||||
import { getServerUrl } from "@/utils/get-server-url";
|
||||
import { AgentContentPage } from "@/components/wrappers/dashboard/agent/agent-content";
|
||||
import { AgentDialog } from "@/features/agents/components/agent.dialog";
|
||||
import { AgentType } from "@/features/agents/agents.schema";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ agentId: string }>) {
|
||||
export default async function RoutePage(
|
||||
props: PageParams<{ agentId: string }>,
|
||||
) {
|
||||
const { agentId } = await props.params;
|
||||
|
||||
const {agentId} = await props.params
|
||||
const agent = await db.query.agent.findFirst({
|
||||
where: eq(drizzleDb.schemas.agent.id, agentId),
|
||||
with: {
|
||||
databases: true,
|
||||
},
|
||||
});
|
||||
|
||||
const agent = await db.query.agent.findFirst({
|
||||
where: eq(drizzleDb.schemas.agent.id, agentId),
|
||||
with: {
|
||||
databases: true
|
||||
}
|
||||
})
|
||||
if (!agent) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (!agent) {
|
||||
notFound()
|
||||
}
|
||||
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
|
||||
|
||||
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(agent.name)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentDialog agent={agent as AgentType & { id: string }} typeTrigger={"edit"}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
|
||||
</div>
|
||||
</div>
|
||||
</PageTitle>
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(agent.name)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentDialog
|
||||
agent={agent as AgentType & { id: string }}
|
||||
typeTrigger={"edit"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"} />
|
||||
</div>
|
||||
</div>
|
||||
</PageTitle>
|
||||
</div>
|
||||
|
||||
{agent.description && (
|
||||
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription>
|
||||
)}
|
||||
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
||||
<AgentContentPage
|
||||
agent={agent}
|
||||
edgeKey={edgeKey}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
{agent.description && (
|
||||
<PageDescription className="mt-5 sm:mt-0">
|
||||
{agent.description}
|
||||
</PageDescription>
|
||||
)}
|
||||
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
||||
<AgentContentPage agent={agent} edgeKey={edgeKey} />
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
import { PageParams } from "@/types/next";
|
||||
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
|
||||
import { notFound } from "next/navigation";
|
||||
import { DatabaseForm } from "@/components/wrappers/dashboard/database/database-form/database-form";
|
||||
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ databaseId: string }>) {
|
||||
const { databaseId } = await props.params;
|
||||
|
||||
const dbItem = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.id, databaseId),
|
||||
});
|
||||
|
||||
if (!dbItem) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Edit {dbItem.name}</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<DatabaseForm
|
||||
databaseId={databaseId}
|
||||
// @ts-ignore
|
||||
defaultValues={{ ...dbItem, dbms: dbItem.dbms ?? "inactive", description: dbItem.description ?? undefined }}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +1,95 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import {notFound} from "next/navigation";
|
||||
import {Metadata} from "next";
|
||||
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
|
||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
|
||||
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
||||
import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button";
|
||||
import {EditOrganizationDialog} from "@/features/organization/components/edit-organization.dialog";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {GearIcon} from "@radix-ui/react-icons";
|
||||
import {db} from "@/db";
|
||||
import {isNull} from "drizzle-orm";
|
||||
import { PageParams } from "@/types/next";
|
||||
import {
|
||||
Page,
|
||||
PageActions,
|
||||
PageContent,
|
||||
PageHeader,
|
||||
PageTitle,
|
||||
} from "@/features/layout/page";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { getActiveMember, getOrganization } from "@/lib/auth/auth";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Metadata } from "next";
|
||||
import { OrganizationTabs } from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
|
||||
import { getOrganizationChannels } from "@/db/services/notification-channel";
|
||||
import { computeOrganizationPermissions } from "@/lib/acl/organization-acl";
|
||||
import { getOrganizationStorageChannels } from "@/db/services/storage-channel";
|
||||
import { DeleteOrganizationButton } from "@/components/wrappers/dashboard/organization/delete-organization-button";
|
||||
import { EditOrganizationDialog } from "@/features/organization/components/edit-organization.dialog";
|
||||
import { db } from "@/db";
|
||||
import { isNull } from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Settings",
|
||||
title: "Settings",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
const organization = await getOrganization({});
|
||||
const user = await currentUser();
|
||||
const activeMember = await getActiveMember()
|
||||
const organization = await getOrganization({});
|
||||
const user = await currentUser();
|
||||
const activeMember = await getActiveMember();
|
||||
|
||||
if (!organization || !activeMember || !user) {
|
||||
notFound();
|
||||
}
|
||||
if (!organization || !activeMember || !user) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const notificationChannels = await getOrganizationChannels(organization.id)
|
||||
const storageChannels = await getOrganizationStorageChannels(organization.id)
|
||||
const permissions = computeOrganizationPermissions(activeMember);
|
||||
const notificationChannels = await getOrganizationChannels(organization.id);
|
||||
const storageChannels = await getOrganizationStorageChannels(organization.id);
|
||||
const permissions = computeOrganizationPermissions(activeMember);
|
||||
|
||||
const users = await db.query.user.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt)
|
||||
});
|
||||
const users = await db.query.user.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt),
|
||||
});
|
||||
|
||||
const organizationWithMembers = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.id, organization.id),
|
||||
const organizationWithMembers = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.id, organization.id),
|
||||
with: {
|
||||
members: {
|
||||
with: {
|
||||
members: {
|
||||
with: {
|
||||
user: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organizationWithMembers) notFound();
|
||||
if (!organizationWithMembers) notFound();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
Organization settings
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
{permissions.canManageSettings && organization.slug !== "default" && (
|
||||
<EditOrganizationDialog
|
||||
organization={organizationWithMembers}
|
||||
users={users}
|
||||
currentUser={user}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{permissions.canManageDangerZone && organization.slug !== "default" && (
|
||||
<DeleteOrganizationButton organizationSlug={organization.slug}/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<OrganizationTabs
|
||||
activeMember={activeMember}
|
||||
organization={organization}
|
||||
notificationChannels={notificationChannels}
|
||||
storageChannels={storageChannels}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">Organization settings</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
{permissions.canManageSettings &&
|
||||
organization.slug !== "default" && (
|
||||
<EditOrganizationDialog
|
||||
organization={organizationWithMembers}
|
||||
users={users}
|
||||
currentUser={user}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{permissions.canManageDangerZone &&
|
||||
organization.slug !== "default" && (
|
||||
<DeleteOrganizationButton
|
||||
organizationSlug={organization.slug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageTitle>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<OrganizationTabs
|
||||
activeMember={activeMember}
|
||||
organization={organization}
|
||||
notificationChannels={notificationChannels}
|
||||
storageChannels={storageChannels}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
import React, {ReactNode} from "react";
|
||||
import {redirect} from "next/navigation";
|
||||
import { ReactNode } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar";
|
||||
import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sidebar";
|
||||
import {Header} from "@/features/layout/Header";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {ThemeMetaUpdater} from "@/features/browser/theme-meta-updater";
|
||||
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { AppSidebar } from "@/components/wrappers/dashboard/common/sidebar/app-sidebar";
|
||||
import { Header } from "@/features/layout/Header";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { ThemeMetaUpdater } from "@/features/browser/theme-meta-updater";
|
||||
|
||||
export default async function Layout({children}: { children: ReactNode }) {
|
||||
const user = await currentUser();
|
||||
if (!user) redirect("/login");
|
||||
export default async function Layout({ children }: { children: ReactNode }) {
|
||||
const user = await currentUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="flex flex-col lg:flex-row w-full">
|
||||
<ThemeMetaUpdater/>
|
||||
<AppSidebar/>
|
||||
<SidebarInset>
|
||||
<Header/>
|
||||
<main className="h-full">{children}</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="flex flex-col lg:flex-row w-full">
|
||||
<ThemeMetaUpdater />
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<Header />
|
||||
<main className="h-full">{children}</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
}) => {
|
||||
try {
|
||||
const body: BodyPatch = await request.json();
|
||||
console.log("body", body);
|
||||
|
||||
const status = body.status
|
||||
const backupId = body.backupId
|
||||
@@ -131,7 +132,7 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
await sendNotificationsBackupRestore(database, `${status}_backup` as EventKind);
|
||||
await sendNotificationsBackupRestore(database, status == "failed" ? "error_backup" : "success_backup" as EventKind);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -63,8 +63,7 @@ export async function POST(
|
||||
.set({status: body.status as RestorationStatus})
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
|
||||
|
||||
await sendNotificationsBackupRestore(database, body.status == "error" ? "error_restore" : "success_restore");
|
||||
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
|
||||
|
||||
const response = {
|
||||
message: true,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
PROJECT_URL: process.env.PROJECT_URL,
|
||||
PROJECT_NAME: process.env.PROJECT_NAME,
|
||||
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
|
||||
});
|
||||
return NextResponse.json({
|
||||
PROJECT_URL: process.env.PROJECT_URL,
|
||||
PROJECT_NAME: process.env.PROJECT_NAME,
|
||||
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
|
||||
});
|
||||
}
|
||||
|
||||
+30
-82
@@ -1,85 +1,33 @@
|
||||
// "use client"
|
||||
//
|
||||
// import {useSearchParams} from "next/navigation"
|
||||
//
|
||||
// enum Error {
|
||||
// Configuration = "Configuration",
|
||||
// }
|
||||
//
|
||||
// const errorMap = {
|
||||
// [Error.Configuration]: (
|
||||
// <p>
|
||||
// There was a problem when trying to authenticate. Please contact us if this
|
||||
// error persists. Unique error code:{" "}
|
||||
// <code className="rounded-sm bg-slate-100 p-1 text-xs">Configuration</code>
|
||||
// </p>
|
||||
// ),
|
||||
// }
|
||||
//
|
||||
// export default function AuthErrorPage() {
|
||||
// const search = useSearchParams()
|
||||
// const error = search.get("error") as Error
|
||||
//
|
||||
// return (
|
||||
// <div className="flex h-screen w-full flex-col items-center justify-center">
|
||||
// <a
|
||||
// href="#"
|
||||
// className="block max-w-sm rounded-lg border border-gray-200 bg-white p-6 text-center shadow-sm hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700"
|
||||
// >
|
||||
// <h5 className="mb-2 flex flex-row items-center justify-center gap-2 text-xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
// Something went wrong
|
||||
// </h5>
|
||||
// <div className="font-normal text-gray-700 dark:text-gray-400">
|
||||
// {errorMap[error] || "Please contact us if this error persists."}
|
||||
// </div>
|
||||
// </a>
|
||||
// </div>
|
||||
// )
|
||||
// }
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import React, { Suspense } from "react"
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useSession } from "@/lib/auth/auth-client";
|
||||
|
||||
enum Error {
|
||||
Configuration = "Configuration",
|
||||
export default function ErrorPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { data: session, isPending } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const error = searchParams.get("error");
|
||||
const params = new URLSearchParams();
|
||||
if (error) {
|
||||
params.set("error", error);
|
||||
}
|
||||
|
||||
const destination = session ? "/dashboard/home" : "/login";
|
||||
router.replace(`${destination}?${params.toString()}`);
|
||||
}, [isPending, session, router, searchParams]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const errorMap = {
|
||||
[Error.Configuration]: (
|
||||
<p>
|
||||
There was a problem when trying to authenticate. Please contact us if this
|
||||
error persists. Unique error code:{" "}
|
||||
<code className="rounded-sm bg-slate-100 p-1 text-xs">Configuration</code>
|
||||
</p>
|
||||
),
|
||||
}
|
||||
|
||||
function AuthErrorPageContent() {
|
||||
const search = useSearchParams()
|
||||
const error = search.get("error") as Error
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col items-center justify-center">
|
||||
<a
|
||||
href="#"
|
||||
className="block max-w-sm rounded-lg border border-gray-200 bg-white p-6 text-center shadow-sm hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700"
|
||||
>
|
||||
<h5 className="mb-2 flex flex-row items-center justify-center gap-2 text-xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
Something went wrong
|
||||
</h5>
|
||||
<div className="font-normal text-gray-700 dark:text-gray-400">
|
||||
{errorMap[error] || "Please contact us if this error persists."}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AuthErrorPage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<AuthErrorPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
+32
-28
@@ -1,38 +1,42 @@
|
||||
import React from "react";
|
||||
import type {Metadata} from "next";
|
||||
import type { Metadata } from "next";
|
||||
import type React from "react";
|
||||
import "./globals.css";
|
||||
import {Providers} from "./providers";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer";
|
||||
import {author, geistMono, poppins} from "@/fonts/fonts";
|
||||
import { ConsoleSilencer } from "@/components/wrappers/common/console-silencer";
|
||||
import { author, geistMono, poppins } from "@/fonts/fonts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
const title = process.env.PROJECT_NAME ?? "App Title";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: title,
|
||||
template: `%s - ${title}`
|
||||
},
|
||||
description: process.env.PROJECT_DESCRIPTION ?? undefined,
|
||||
title: {
|
||||
default: title,
|
||||
template: `%s - ${title}`,
|
||||
},
|
||||
description: process.env.PROJECT_DESCRIPTION ?? undefined,
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-title" content={title}/>
|
||||
</head>
|
||||
<body className={cn(poppins.variable, author.variable, geistMono.variable, "font-sans h-full")}>
|
||||
<ConsoleSilencer/>
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-title" content={title} />
|
||||
</head>
|
||||
<body
|
||||
className={cn(
|
||||
poppins.variable,
|
||||
author.variable,
|
||||
geistMono.variable,
|
||||
"font-sans h-full",
|
||||
)}
|
||||
>
|
||||
<ConsoleSilencer />
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+14
-10
@@ -1,15 +1,19 @@
|
||||
import BackButton from "@/components/wrappers/common/button/back-button";
|
||||
|
||||
export default async function NotFound() {
|
||||
return(
|
||||
<div className="flex items-center min-h-screen px-4 py-12 sm:px-6 md:px-8 lg:px-12 xl:px-16">
|
||||
<div className="w-full space-y-6 text-center">
|
||||
<div className="space-y-3">
|
||||
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">Not found</h1>
|
||||
<p className="leading-7 [&:not(:first-child)]:mt-6">The content you are trying to view is not available.</p>
|
||||
</div>
|
||||
<BackButton>Go home</BackButton>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex items-center min-h-screen px-4 py-12 sm:px-6 md:px-8 lg:px-12 xl:px-16">
|
||||
<div className="w-full space-y-6 text-center">
|
||||
<div className="space-y-3">
|
||||
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||
Not found
|
||||
</h1>
|
||||
<p className="leading-7 not-first:mt-6">
|
||||
The content you are trying to view is not available.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
<BackButton>Go home</BackButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+19
-21
@@ -1,30 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import {PropsWithChildren, Suspense} from "react";
|
||||
import {ThemeProvider} from "@/features/theme/theme-provider";
|
||||
|
||||
import {Toaster} from "@/components/ui/sonner";
|
||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
||||
import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { type PropsWithChildren, Suspense } from "react";
|
||||
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { ErrorLayout } from "@/components/wrappers/common/error-layout";
|
||||
import { ThemeMetaUpdaterRoot } from "@/features/browser/theme-meta-updater-root";
|
||||
import { ThemeProvider } from "@/features/theme/theme-provider";
|
||||
|
||||
export type ProviderProps = PropsWithChildren<{}>;
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export const Providers = (props: ProviderProps) => {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
>
|
||||
<ThemeMetaUpdaterRoot/>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toaster/>
|
||||
{props.children}
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</Suspense>
|
||||
);
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ThemeMetaUpdaterRoot />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorLayout>
|
||||
<Toaster />
|
||||
{props.children}
|
||||
</ErrorLayout>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: portabase-dev-func
|
||||
|
||||
services:
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:latest
|
||||
command: start-dev --import-realm
|
||||
# environment:
|
||||
# KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||
# KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||
ports:
|
||||
- "3056:8080"
|
||||
volumes:
|
||||
- keycloak-data:/opt/keycloak/data
|
||||
- ./seeds/keycloak:/opt/keycloak/data/import:ro
|
||||
- ./export:/tmp/export
|
||||
pocket-id:
|
||||
image: ghcr.io/pocket-id/pocket-id
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- APP_URL=http://localhost:3055
|
||||
- ENCRYPTION_KEY=QwHyjbZvSsDUAcjpdmSPsuYxaH6vET6OeBaeLwXccCb43L6Om3W1AoU5pKIJTzYr
|
||||
ports:
|
||||
- 3055:1411
|
||||
volumes:
|
||||
- pocket-id-data:/app/data
|
||||
- ./seeds/pocket-id:/seed:ro
|
||||
healthcheck:
|
||||
test: "curl -f http://localhost:1411/healthz"
|
||||
interval: 1m30s
|
||||
timeout: 5s
|
||||
retries: 2
|
||||
start_period: 10s
|
||||
volumes:
|
||||
keycloak-data:
|
||||
pocket-id-data:
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
# context: .
|
||||
# dockerfile: docker/dockerfile/Dockerfile
|
||||
# target: prod
|
||||
image: portabase/portabase:1.2.7-rc.1
|
||||
image: portabase/portabase:1.4.0
|
||||
ports:
|
||||
- '8887:80'
|
||||
environment:
|
||||
|
||||
+2
-1
@@ -12,7 +12,7 @@ services:
|
||||
- POSTGRES_USER=devuser
|
||||
- POSTGRES_PASSWORD=changeme
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U devuser -d devdb" ]
|
||||
test: ["CMD-SHELL", "pg_isready -U devuser -d devdb"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -30,6 +30,7 @@ services:
|
||||
- "localhost:host-gateway"
|
||||
volumes:
|
||||
- ./private/uploads/tmp:/data/uploads/tmp
|
||||
user: "1000:1000"
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
|
||||
+15
-13
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.2.8",
|
||||
"version": "1.4.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
@@ -14,6 +14,8 @@
|
||||
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/passkey": "^1.4.19",
|
||||
"@better-auth/sso": "^1.4.19",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
@@ -48,12 +50,12 @@
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/nodemailer": "^6.4.22",
|
||||
"@types/nodemailer": "^6.4.23",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@zenstackhq/runtime": "2.14.2",
|
||||
"argon2": "^0.43.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.4.5",
|
||||
"better-auth": "1.4.18",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -68,7 +70,7 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.553.0",
|
||||
"minio": "^8.0.6",
|
||||
"motion": "^12.34.0",
|
||||
"motion": "^12.34.3",
|
||||
"next": "16.1.5",
|
||||
"next-safe-action": "^7.10.8",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -83,7 +85,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-dropzone": "^14.4.1",
|
||||
"react-email": "^4.3.2",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-twc": "^1.5.1",
|
||||
@@ -93,8 +95,8 @@
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"swiper": "^12.1.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"swiper": "^12.1.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"uuid": "^11.1.0",
|
||||
"vaul": "^1.1.2",
|
||||
"ws": "^8.19.0",
|
||||
@@ -104,7 +106,7 @@
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@react-email/preview-server": "4.3.2",
|
||||
"@react-email/render": "^2.0.4",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||
"@types/node": "^22.19.11",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
@@ -113,19 +115,19 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@zenstackhq/openapi": "^2.22.1",
|
||||
"@zenstackhq/tanstack-query": "^2.22.2",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"baseline-browser-mapping": "^2.10.0",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"esbuild": "^0.27.3",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||
"framer-motion": "^12.34.0",
|
||||
"framer-motion": "^12.34.3",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"zenstack": "2.14.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
|
||||
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
|
||||
}
|
||||
|
||||
Generated
+943
-538
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,3 @@
|
||||
export interface AuthProviderConfig {
|
||||
id: "google" | "github" | "credential";
|
||||
isActive: boolean;
|
||||
icon: string;
|
||||
isManual?: boolean;
|
||||
credentials?: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export const PORTABASE_DEFAULT_SETTINGS = {
|
||||
@@ -74,35 +64,3 @@ export const PORTABASE_DEFAULT_SETTINGS = {
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
|
||||
{
|
||||
id: "google",
|
||||
icon: "hugeicons:chrome",
|
||||
isActive: Boolean(process.env.AUTH_GOOGLE_METHOD),
|
||||
// isManual: true,
|
||||
credentials: {
|
||||
clientId: process.env.AUTH_GOOGLE_ID || "",
|
||||
clientSecret: process.env.AUTH_GOOGLE_SECRET || "",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
icon: "iconoir:github",
|
||||
isActive: Boolean(process.env.AUTH_GITHUB_METHOD),
|
||||
credentials: {
|
||||
clientId: process.env.AUTH_GITHUB_ID || "",
|
||||
clientSecret: process.env.AUTH_GITHUB_SECRET || "",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "credential",
|
||||
isActive: true,
|
||||
icon: "proicons:key",
|
||||
isManual: true,
|
||||
credentials: {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,75 +1,81 @@
|
||||
import {NextRequest, NextResponse} from "next/server";
|
||||
import {loggingMiddleware} from "@/middleware/loggingMiddleware";
|
||||
import {errorHandler} from "@/middleware/errorHandler";
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {headers} from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { loggingMiddleware } from "@/middleware/loggingMiddleware";
|
||||
import { errorHandler } from "@/middleware/errorHandler";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
const url = request.nextUrl.clone();
|
||||
const redirectUrl = encodeURIComponent(request.nextUrl.pathname)
|
||||
const url = request.nextUrl.clone();
|
||||
const redirectUrl = encodeURIComponent(request.nextUrl.pathname);
|
||||
|
||||
if (url.pathname.startsWith("/dashboard")) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
if (!session) {
|
||||
return NextResponse.redirect(new URL(`/login?redirect=${redirectUrl}`, request.url));
|
||||
}
|
||||
if (session.user.banned) {
|
||||
await auth.api.signOut({headers: await headers()});
|
||||
return NextResponse.redirect(new URL("/login?error=banned", request.url));
|
||||
}
|
||||
if (session.user.role === "pending") {
|
||||
await auth.api.signOut({headers: await headers()});
|
||||
return NextResponse.redirect(new URL(`/login?error=pending?redirect=${redirectUrl}`, request.url));
|
||||
}
|
||||
if (url.pathname === "/dashboard") {
|
||||
return NextResponse.redirect(new URL(`/dashboard/home`, request.url));
|
||||
}
|
||||
return NextResponse.next();
|
||||
if (url.pathname.startsWith("/dashboard")) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
if (!session) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/login?redirect=${redirectUrl}`, request.url),
|
||||
);
|
||||
}
|
||||
if (session.user.banned) {
|
||||
await auth.api.signOut({ headers: await headers() });
|
||||
return NextResponse.redirect(new URL("/login?error=banned", request.url));
|
||||
}
|
||||
if (session.user.role === "pending") {
|
||||
await auth.api.signOut({ headers: await headers() });
|
||||
return NextResponse.redirect(
|
||||
new URL(`/login?error=pending?redirect=${redirectUrl}`, request.url),
|
||||
);
|
||||
}
|
||||
if (url.pathname === "/dashboard") {
|
||||
return NextResponse.redirect(new URL(`/dashboard/home`, request.url));
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/auth")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
if (url.pathname.startsWith("/api/auth")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api")) {
|
||||
const routeExists = checkRouteExists(url.pathname);
|
||||
if (!routeExists) {
|
||||
return new NextResponse(JSON.stringify({message: "This API route does not exist.", status: 404}), {
|
||||
status: 404,
|
||||
headers: {"Content-Type": "application/json"},
|
||||
});
|
||||
}
|
||||
}
|
||||
try {
|
||||
loggingMiddleware(request);
|
||||
} catch (err) {
|
||||
errorHandler(err);
|
||||
if (url.pathname.startsWith("/api")) {
|
||||
const routeExists = checkRouteExists(url.pathname);
|
||||
if (!routeExists) {
|
||||
return new NextResponse(
|
||||
JSON.stringify({
|
||||
message: "This API route does not exist.",
|
||||
status: 404,
|
||||
}),
|
||||
{
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
loggingMiddleware(request);
|
||||
} catch (err) {
|
||||
errorHandler(err);
|
||||
}
|
||||
}
|
||||
|
||||
function checkRouteExists(pathname: string) {
|
||||
const routePatterns = [
|
||||
/^\/api\/agent\/[^/]+\/status\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/backup\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/backup\/upload\/init\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/backup\/upload\/status\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/restore\/?$/,
|
||||
/^\/api\/files\/images\/[^/]+\/?$/,
|
||||
/^\/api\/files\/backups\/?$/,
|
||||
/^\/api\/tus\/hooks\/?$/,
|
||||
/^\/api\/events\/?$/,
|
||||
/^\/api\/config\/?$/,
|
||||
/^\/api\/google\/drive\/callback\/?$/,
|
||||
];
|
||||
return routePatterns.some((pattern) => pattern.test(pathname));
|
||||
const routePatterns = [
|
||||
/^\/api\/agent\/[^/]+\/status\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/backup\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/backup\/upload\/init\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/backup\/upload\/status\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/restore\/?$/,
|
||||
/^\/api\/files\/images\/[^/]+\/?$/,
|
||||
/^\/api\/files\/backups\/?$/,
|
||||
/^\/api\/tus\/hooks\/?$/,
|
||||
/^\/api\/events\/?$/,
|
||||
/^\/api\/config\/?$/,
|
||||
/^\/api\/google\/drive\/callback\/?$/,
|
||||
];
|
||||
return routePatterns.some((pattern) => pattern.test(pathname));
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/api/:path*",
|
||||
"/dashboard/:path*",
|
||||
"/dashboard",
|
||||
]
|
||||
matcher: ["/api/:path*", "/dashboard/:path*", "/dashboard"],
|
||||
};
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -11,17 +11,17 @@ fi
|
||||
VERSION=$1
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
if [ "$CURRENT_BRANCH" = "main" ]; then
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: On 'main' branch, only release tags (X.Y.Z) are allowed."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
echo "Error: On branch '$CURRENT_BRANCH', only RC tags matching X.Y.Z-rc.W are allowed (e.g., 1.0.0-rc.1)."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
#if [ "$CURRENT_BRANCH" = "main" ]; then
|
||||
# if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
# echo "Error: On 'main' branch, only release tags (X.Y.Z) are allowed."
|
||||
# exit 1
|
||||
# fi
|
||||
#else
|
||||
# if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
# echo "Error: On branch '$CURRENT_BRANCH', only RC tags matching X.Y.Z-rc.W are allowed (e.g., 1.0.0-rc.1)."
|
||||
# exit 1
|
||||
# fi
|
||||
#fi
|
||||
|
||||
CLEAN_VERSION=${VERSION#v}
|
||||
CURRENT_DATE=$(date +%Y-%m-%d)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
@@ -6,7 +6,7 @@ import {env} from "@/env.mjs";
|
||||
|
||||
interface EmailCreateUserProps {
|
||||
email: string;
|
||||
password: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export const EmailCreateUser = ({email, password}: EmailCreateUserProps) => {
|
||||
@@ -23,9 +23,15 @@ export const EmailCreateUser = ({email, password}: EmailCreateUserProps) => {
|
||||
<strong>Email: </strong>{email}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Default password: </strong>{password}
|
||||
</Text>
|
||||
{password ? (
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Default password: </strong>{password}
|
||||
</Text>
|
||||
) : (
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
You can log in using one of the single sign-on (SSO) providers configured by your administrator.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Section className="mt-[32px] mb-[32px] text-center">
|
||||
<Button
|
||||
|
||||
@@ -1,121 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import {useEffect, useState} from "react";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Form} from "@/components/ui/form";
|
||||
import {LoginSchema, LoginType} from "@/components/wrappers/auth/login/login-form/login-form.schema";
|
||||
import {signIn} from "@/lib/auth/auth-client";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useZodForm,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import {
|
||||
LoginSchema,
|
||||
type LoginType,
|
||||
} from "@/components/wrappers/auth/login/login-form/login-form.schema";
|
||||
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||
import { signIn } from "@/lib/auth/auth-client";
|
||||
|
||||
export type loginFormProps = {
|
||||
defaultValues?: LoginType;
|
||||
defaultValues?: LoginType;
|
||||
isPasskeyEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const LoginForm = (props: loginFormProps) => {
|
||||
const { isPasskeyEnabled = false } = props;
|
||||
|
||||
const url = useSearchParams();
|
||||
|
||||
const [urlParams, setUrlParams] = useState<URLSearchParams>();
|
||||
const form = useZodForm({
|
||||
schema: LoginSchema,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
setUrlParams(urlParams);
|
||||
const error = urlParams.get("error");
|
||||
if (error?.includes("pending")) {
|
||||
toast.error("Your account is not active.");
|
||||
urlParams.delete("error");
|
||||
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
|
||||
}
|
||||
if (error?.includes("invalid_or_expired_token")) {
|
||||
toast.error("Password reset invalid token.");
|
||||
urlParams.delete("error");
|
||||
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
|
||||
}
|
||||
}, []);
|
||||
const router = useRouter();
|
||||
|
||||
const form = useZodForm({
|
||||
schema: LoginSchema,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: LoginType) => {
|
||||
await signIn.email(
|
||||
{
|
||||
password: values.password,
|
||||
email: values.email,
|
||||
callbackURL: urlParams?.get("redirect") ?? "/dashboard",
|
||||
},
|
||||
{
|
||||
onSuccess: (context) => {
|
||||
if (context.data.twoFactorRedirect) {
|
||||
//@ts-ignore
|
||||
router.push("/guard?redirect=" + encodeURIComponent(context.data.callbackURL || "/dashboard"));
|
||||
}
|
||||
|
||||
toast.success("Login success");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message);
|
||||
},
|
||||
}
|
||||
);
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: LoginType) => {
|
||||
await signIn.email(
|
||||
{
|
||||
password: values.password,
|
||||
email: values.email,
|
||||
callbackURL: url?.get("redirect") ?? "/dashboard",
|
||||
},
|
||||
});
|
||||
{
|
||||
onSuccess: (context) => {
|
||||
if (context.data.twoFactorRedirect) {
|
||||
router.push(
|
||||
"/guard?redirect=" +
|
||||
encodeURIComponent(context.data.callbackURL || "/dashboard"),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4 mb-1"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoComplete="email" autoFocus placeholder="example@portabase.io" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Password</FormLabel>
|
||||
<div className="text-center text-sm">
|
||||
<Link href={"/forgot-password"} className="hover:underline ml-1">
|
||||
Forgot your password ?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<PasswordInput autoComplete="current-password webauthn"
|
||||
placeholder={"Enter your password"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<ButtonWithLoading className="mt-2 h-11" isPending={mutation.isPending}>
|
||||
Login
|
||||
</ButtonWithLoading>
|
||||
</Form>
|
||||
);
|
||||
toast.success("Login success");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4 mb-1"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
placeholder="example@portabase.io"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Password</FormLabel>
|
||||
<div className="text-center text-sm">
|
||||
<Link
|
||||
href={"/forgot-password"}
|
||||
className="hover:underline ml-1"
|
||||
>
|
||||
Forgot your password ?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
autoComplete={
|
||||
isPasskeyEnabled
|
||||
? "current-password webauthn"
|
||||
: "current-password"
|
||||
}
|
||||
placeholder={"Enter your password"}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<ButtonWithLoading className="mt-2 h-11" isPending={mutation.isPending}>
|
||||
Login
|
||||
</ButtonWithLoading>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,156 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {Form} from "@/components/ui/form";
|
||||
import {TooltipProvider, TooltipTrigger, Tooltip, TooltipContent} from "@/components/ui/tooltip";
|
||||
import {RegisterSchema, RegisterType} from "@/components/wrappers/auth/register/register-form/register-form.schema";
|
||||
import {signUp} from "@/lib/auth/auth-client";
|
||||
import {useZodForm} from "@/components/ui/form";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
import {FormField} from "@/components/ui/form";
|
||||
import {FormItem} from "@/components/ui/form";
|
||||
import {FormLabel} from "@/components/ui/form";
|
||||
import {FormControl} from "@/components/ui/form";
|
||||
import {FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
RegisterSchema,
|
||||
RegisterType,
|
||||
} from "@/components/wrappers/auth/register/register-form/register-form.schema";
|
||||
import { signUp } from "@/lib/auth/auth-client";
|
||||
import { useZodForm } from "@/components/ui/form";
|
||||
import { CardAuth } from "@/features/layout/card-auth";
|
||||
import { FormField } from "@/components/ui/form";
|
||||
import { FormItem } from "@/components/ui/form";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { FormControl } from "@/components/ui/form";
|
||||
import { FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import {Info} from "lucide-react";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
export type registerFormProps = {
|
||||
defaultValues?: RegisterType;
|
||||
defaultValues?: RegisterType;
|
||||
};
|
||||
|
||||
export const RegisterForm = (props: registerFormProps) => {
|
||||
const form = useZodForm({
|
||||
schema: RegisterSchema,
|
||||
});
|
||||
|
||||
const form = useZodForm({
|
||||
schema: RegisterSchema,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: RegisterType) => {
|
||||
// @ts-ignore
|
||||
await signUp.email(values, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Account successfully created`);
|
||||
router.refresh();
|
||||
router.push(`/login`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message);
|
||||
},
|
||||
});
|
||||
const router = useRouter();
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: RegisterType) => {
|
||||
await signUp.email(values, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Account successfully created`);
|
||||
router.refresh();
|
||||
router.push(`/login`);
|
||||
},
|
||||
});
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<CardAuth>
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Create an account</h1>
|
||||
<p className="text-balance text-muted-foreground">Enter your informations below to register</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Your name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="example@portabase.io" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex">
|
||||
Password
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="ml-3" size="15"/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Min. 8 characters, 1 uppercase (A-Z), 1 lowercase (a-z), 1
|
||||
number (0-9), 1 special character (!, @,
|
||||
etc.)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder="Your password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password Confirmation</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder="Conform your password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" className="h-11" disabled={mutation.isPending}>
|
||||
Sign up
|
||||
</Button>
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Already have an account ?{" "}
|
||||
<Link href="/login" className="underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</Form>
|
||||
</CardContent>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<CardAuth>
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Create an account</h1>
|
||||
<p className="text-balance text-muted-foreground">
|
||||
Enter your informations below to register
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Your name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="example@portabase.io" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex">
|
||||
Password
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info size="15" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Min. 8 characters, 1 uppercase (A-Z), 1 lowercase
|
||||
(a-z), 1 number (0-9), 1 special character (!, @,
|
||||
etc.)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder="Your password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password Confirmation</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
placeholder="Conform your password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-11"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Already have an account ?{" "}
|
||||
<Link href="/login" className="underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,29 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { authClient, passkey } from "@/lib/auth/auth-client";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {AuthProviderConfig} from "../../../../portabase.config";
|
||||
import {Icon} from "@iconify/react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { AuthProviderConfig } from "@/lib/auth/config";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig[] }) {
|
||||
const socialProviders = providers.filter(p => p.isActive && !p.isManual);
|
||||
const socialProviders = providers.filter((p) => p.isActive && p.type !== "credential");
|
||||
const router = useRouter();
|
||||
|
||||
const [isLoading, setIsLoading] = useState<string | null>(null);
|
||||
|
||||
const handleSocialSignIn = async (providerId: string) => {
|
||||
setIsLoading(providerId);
|
||||
const handleSocialSignIn = async (provider: AuthProviderConfig) => {
|
||||
setIsLoading(provider.id);
|
||||
try {
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider: providerId as "google" | "github",
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error("An error occurred while signing in with the provider. Please try again.");
|
||||
let result;
|
||||
if (provider.id === "passkey") {
|
||||
result = await authClient.signIn.passkey({
|
||||
fetchOptions: {
|
||||
onSuccess() {
|
||||
router.push("/dashboard");
|
||||
},
|
||||
onError() {
|
||||
toast.error("An error occurred during passkey authentication. Please try again.");
|
||||
},
|
||||
},
|
||||
});
|
||||
} else if (provider.type === "sso") {
|
||||
result = await authClient.signIn.sso({
|
||||
providerId: provider.id,
|
||||
providerType: "oidc",
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
} else {
|
||||
result = await authClient.signIn.social({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
provider: provider.id as any,
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
}
|
||||
|
||||
if (result?.error) {
|
||||
toast.error("An error occurred while signing in with the provider. Please try again.");
|
||||
} else if (provider.id !== "passkey") {
|
||||
toast.success("Redirecting to provider...");
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -38,29 +62,24 @@ export function SocialAuthButtons({ providers }: { providers: AuthProviderConfig
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{socialProviders.map((provider) => (
|
||||
<Button key={provider.id} variant="outline" className="w-full gap-2" onClick={() => handleSocialSignIn(provider.id)} disabled={!!isLoading}>
|
||||
{isLoading === provider.id ? <Loader2 className="h-4 w-4 animate-spin" /> :
|
||||
<Icon icon={provider.icon} className="h-4 w-4"/>
|
||||
}
|
||||
<span>{PROVIDERS_TEXT[provider.id].title}</span>
|
||||
<Button key={provider.id} variant="outline" className="w-full gap-2" onClick={() => handleSocialSignIn(provider)} disabled={!!isLoading}>
|
||||
{isLoading === provider.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : provider.icon.startsWith("/") || provider.icon.startsWith("http") ? (
|
||||
<Image
|
||||
src={provider.icon}
|
||||
alt={provider.id || "icon"}
|
||||
width={16}
|
||||
height={16}
|
||||
className="h-4 w-4"
|
||||
unoptimized={provider.icon.startsWith("http")}
|
||||
/>
|
||||
) : (
|
||||
<Icon icon={provider.icon} className="h-4 w-4" />
|
||||
)}
|
||||
<span>{provider.title || provider.name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const PROVIDERS_TEXT = {
|
||||
credential: {
|
||||
title: "Password",
|
||||
description: "Use your email address and password to sign in."
|
||||
},
|
||||
google: {
|
||||
title: "Google",
|
||||
description: "Sign in with your Google account."
|
||||
},
|
||||
github: {
|
||||
title: "GitHub",
|
||||
description: "Sign in with your GitHub account."
|
||||
}
|
||||
}
|
||||
@@ -1,161 +1,198 @@
|
||||
"use client"
|
||||
import {Button, ButtonVariantsProps} from "@/components/ui/button";
|
||||
import {ReactNode, useState} from "react";
|
||||
import {Loader2} from "lucide-react";
|
||||
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
"use client";
|
||||
import { Button, ButtonVariantsProps } from "@/components/ui/button";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
export type ButtonWithConfirmProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
button?: {
|
||||
main: {
|
||||
className?: string;
|
||||
type?: "button" | "submit" | "reset" | undefined;
|
||||
text?: string;
|
||||
icon?: any;
|
||||
variant?: ButtonVariantsProps["variant"];
|
||||
size?: ButtonVariantsProps["size"];
|
||||
disabled?: boolean;
|
||||
tooltipText?: string;
|
||||
};
|
||||
confirm: {
|
||||
className?: string;
|
||||
text: string;
|
||||
icon?: any;
|
||||
variant?: ButtonVariantsProps["variant"];
|
||||
size?: ButtonVariantsProps["size"];
|
||||
onClick?: () => void;
|
||||
};
|
||||
cancel: {
|
||||
className?: string;
|
||||
text: string;
|
||||
icon?: any;
|
||||
variant?: ButtonVariantsProps["variant"];
|
||||
size?: ButtonVariantsProps["size"];
|
||||
onClick?: () => void;
|
||||
};
|
||||
title: string;
|
||||
description: string;
|
||||
button?: {
|
||||
main: {
|
||||
className?: string;
|
||||
type?: "button" | "submit" | "reset" | undefined;
|
||||
text?: string;
|
||||
icon?: any;
|
||||
variant?: ButtonVariantsProps["variant"];
|
||||
size?: ButtonVariantsProps["size"];
|
||||
disabled?: boolean;
|
||||
tooltipText?: string;
|
||||
};
|
||||
children?: ReactNode;
|
||||
onConfirm?: (e: React.MouseEvent) => void;
|
||||
onCancel?: (e: React.MouseEvent) => void;
|
||||
confirmButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
isPending?: boolean;
|
||||
confirm: {
|
||||
className?: string;
|
||||
text: string;
|
||||
icon?: any;
|
||||
variant?: ButtonVariantsProps["variant"];
|
||||
size?: ButtonVariantsProps["size"];
|
||||
onClick?: () => void;
|
||||
};
|
||||
cancel: {
|
||||
className?: string;
|
||||
text: string;
|
||||
icon?: any;
|
||||
variant?: ButtonVariantsProps["variant"];
|
||||
size?: ButtonVariantsProps["size"];
|
||||
onClick?: () => void;
|
||||
};
|
||||
};
|
||||
children?: ReactNode;
|
||||
onConfirm?: (e: React.MouseEvent) => void;
|
||||
onCancel?: (e: React.MouseEvent) => void;
|
||||
confirmButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
isPending?: boolean;
|
||||
};
|
||||
|
||||
|
||||
export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
||||
const isLegacy = !!props.button;
|
||||
const isDisabled = isLegacy ? !!props.button?.main.disabled : false;
|
||||
const isLegacy = !!props.button;
|
||||
const isDisabled = isLegacy ? !!props.button?.main.disabled : false;
|
||||
|
||||
const handleConfirm = (e: React.MouseEvent) => {
|
||||
const handleConfirm = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isLegacy) {
|
||||
props.button?.confirm.onClick?.();
|
||||
} else {
|
||||
props.onConfirm?.(e);
|
||||
}
|
||||
setIsConfirming(false);
|
||||
};
|
||||
|
||||
const handleCancel = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isLegacy) {
|
||||
props.button?.cancel.onClick?.();
|
||||
} else {
|
||||
props.onCancel?.(e);
|
||||
}
|
||||
setIsConfirming(false);
|
||||
};
|
||||
|
||||
const triggerContent = isLegacy ? (
|
||||
<Button
|
||||
type={props.button?.main.type}
|
||||
disabled={isDisabled}
|
||||
variant={props.button?.main.variant ?? "default"}
|
||||
size={props.button?.main.size ?? "default"}
|
||||
className={props.button?.main.className}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isLegacy) {
|
||||
props.button?.confirm.onClick?.();
|
||||
} else {
|
||||
props.onConfirm?.(e);
|
||||
}
|
||||
setIsConfirming(false);
|
||||
};
|
||||
|
||||
const handleCancel = (e: React.MouseEvent) => {
|
||||
if (!isDisabled) setIsConfirming(true);
|
||||
}}
|
||||
>
|
||||
{props.isPending && <Loader2 className="animate-spin mr-4" size={16} />}
|
||||
{props.button?.main.icon}
|
||||
{props.button?.main.text && <span>{props.button.main.text}</span>}
|
||||
</Button>
|
||||
) : (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isLegacy) {
|
||||
props.button?.cancel.onClick?.();
|
||||
} else {
|
||||
props.onCancel?.(e);
|
||||
}
|
||||
setIsConfirming(false);
|
||||
};
|
||||
setIsConfirming(true);
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const triggerContent = isLegacy ? (
|
||||
<Button
|
||||
type={props.button?.main.type}
|
||||
disabled={isDisabled}
|
||||
variant={props.button?.main.variant ?? "default"}
|
||||
size={props.button?.main.size ?? "default"}
|
||||
className={props.button?.main.className}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isDisabled) setIsConfirming(true);
|
||||
}}
|
||||
>
|
||||
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
|
||||
{props.button?.main.icon}
|
||||
{props.button?.main.text && <span>{props.button.main.text}</span>}
|
||||
</Button>
|
||||
const withTooltip =
|
||||
isLegacy && props.button?.main.tooltipText && isDisabled ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{triggerContent}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{props.button?.main.tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<div onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsConfirming(true);
|
||||
}}>
|
||||
{props.children}
|
||||
</div>
|
||||
triggerContent
|
||||
);
|
||||
|
||||
const withTooltip = isLegacy && props.button?.main.tooltipText && isDisabled ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{triggerContent}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{props.button?.main.tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : triggerContent;
|
||||
|
||||
return (
|
||||
<Popover open={isConfirming} onOpenChange={setIsConfirming}>
|
||||
<PopoverTrigger asChild>
|
||||
{withTooltip}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-80"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseMove={(e) => e.stopPropagation()}
|
||||
onMouseEnter={(e) => e.stopPropagation()}
|
||||
onMouseLeave={(e) => e.stopPropagation()}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
return (
|
||||
<Popover open={isConfirming} onOpenChange={setIsConfirming}>
|
||||
<PopoverTrigger asChild>{withTooltip}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-80"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseMove={(e) => e.stopPropagation()}
|
||||
onMouseEnter={(e) => e.stopPropagation()}
|
||||
onMouseLeave={(e) => e.stopPropagation()}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium leading-none">{props.title}</h4>
|
||||
<p className="text-sm text-muted-foreground">{props.description}</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
variant={
|
||||
isLegacy
|
||||
? (props.button?.confirm.variant ?? "default")
|
||||
: "default"
|
||||
}
|
||||
size={
|
||||
isLegacy ? (props.button?.confirm.size ?? "default") : "default"
|
||||
}
|
||||
className={cn(
|
||||
isLegacy ? props.button?.confirm.className : "",
|
||||
"w-full",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium leading-none">{props.title}</h4>
|
||||
<p className="text-sm text-muted-foreground">{props.description}</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
variant={isLegacy ? (props.button?.confirm.variant ?? "default") : "default"}
|
||||
size={isLegacy ? (props.button?.main.size ?? "default") : "default"}
|
||||
className={cn(isLegacy ? props.button?.main.className : "", "w-full")}
|
||||
>
|
||||
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
|
||||
{isLegacy && props.button?.confirm.icon}
|
||||
<span>{isLegacy ? props.button?.confirm.text : (props.confirmButtonText ?? "Confirm")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={isLegacy ? (props.button?.cancel.variant ?? "outline") : "outline"}
|
||||
onClick={handleCancel}
|
||||
className={cn(isLegacy ? props.button?.main.className : "", "w-full")}
|
||||
size={isLegacy ? (props.button?.main.size ?? "default") : "default"}
|
||||
>
|
||||
{isLegacy ? (props.button?.cancel.text ?? "Cancel") : (props.cancelButtonText ?? "Cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
{props.isPending && (
|
||||
<Loader2 className="animate-spin mr-4" size={16} />
|
||||
)}
|
||||
{isLegacy && props.button?.confirm.icon}
|
||||
<span>
|
||||
{isLegacy
|
||||
? props.button?.confirm.text
|
||||
: (props.confirmButtonText ?? "Confirm")}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
isLegacy
|
||||
? (props.button?.cancel.variant ?? "outline")
|
||||
: "outline"
|
||||
}
|
||||
onClick={handleCancel}
|
||||
className={cn(
|
||||
isLegacy ? props.button?.cancel.className : "",
|
||||
"w-full",
|
||||
)}
|
||||
size={
|
||||
isLegacy ? (props.button?.cancel.size ?? "default") : "default"
|
||||
}
|
||||
>
|
||||
{isLegacy
|
||||
? (props.button?.cancel.text ?? "Cancel")
|
||||
: (props.cancelButtonText ?? "Cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
export const ErrorLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
const url = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const error = url.get("error");
|
||||
|
||||
if (!error) return;
|
||||
|
||||
const errorMessages: Record<string, string> = {
|
||||
pending: "Your account is not active.",
|
||||
invalid_or_expired_token: "Password reset invalid token.",
|
||||
account_already_linked_to_different_user:
|
||||
"An account already exists with the same email address but different sign-in credentials. Sign in using a provider associated with this email address.",
|
||||
USER_NOT_FOUND: "User not found",
|
||||
FAILED_TO_CREATE_USER: "Failed to create user",
|
||||
FAILED_TO_CREATE_SESSION: "Failed to create session",
|
||||
FAILED_TO_UPDATE_USER: "Failed to update user",
|
||||
FAILED_TO_GET_SESSION: "Failed to get session",
|
||||
INVALID_PASSWORD: "Invalid password",
|
||||
INVALID_EMAIL: "Invalid email",
|
||||
INVALID_EMAIL_OR_PASSWORD: "Invalid email or password",
|
||||
INVALID_USER: "Invalid user",
|
||||
SOCIAL_ACCOUNT_ALREADY_LINKED: "Social account already linked",
|
||||
PROVIDER_NOT_FOUND: "Provider not found",
|
||||
INVALID_TOKEN: "Invalid token",
|
||||
TOKEN_EXPIRED: "Token expired",
|
||||
ID_TOKEN_NOT_SUPPORTED: "id_token not supported",
|
||||
FAILED_TO_GET_USER_INFO: "Failed to get user info",
|
||||
USER_EMAIL_NOT_FOUND: "User email not found",
|
||||
EMAIL_NOT_VERIFIED: "Email not verified",
|
||||
PASSWORD_TOO_SHORT: "Password too short",
|
||||
PASSWORD_TOO_LONG: "Password too long",
|
||||
USER_ALREADY_EXISTS: "User already exists.",
|
||||
USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL:
|
||||
"User already exists. Use another email.",
|
||||
EMAIL_CAN_NOT_BE_UPDATED: "Email can not be updated",
|
||||
CREDENTIAL_ACCOUNT_NOT_FOUND: "Credential account not found",
|
||||
SESSION_EXPIRED:
|
||||
"Session expired. Re-authenticate to perform this action.",
|
||||
FAILED_TO_UNLINK_LAST_ACCOUNT: "You can't unlink your last account",
|
||||
ACCOUNT_NOT_FOUND: "Account not found",
|
||||
USER_ALREADY_HAS_PASSWORD:
|
||||
"User already has a password. Provide that to delete the account.",
|
||||
CROSS_SITE_NAVIGATION_LOGIN_BLOCKED:
|
||||
"Cross-site navigation login blocked. This request appears to be a CSRF attack.",
|
||||
VERIFICATION_EMAIL_NOT_ENABLED: "Verification email isn't enabled",
|
||||
EMAIL_ALREADY_VERIFIED: "Email is already verified",
|
||||
EMAIL_MISMATCH: "Email mismatch",
|
||||
SESSION_NOT_FRESH: "Session is not fresh",
|
||||
LINKED_ACCOUNT_ALREADY_EXISTS: "Linked account already exists",
|
||||
INVALID_ORIGIN: "Invalid origin",
|
||||
INVALID_CALLBACK_URL: "Invalid callbackURL",
|
||||
INVALID_REDIRECT_URL: "Invalid redirectURL",
|
||||
INVALID_ERROR_CALLBACK_URL: "Invalid errorCallbackURL",
|
||||
INVALID_NEW_USER_CALLBACK_URL: "Invalid newUserCallbackURL",
|
||||
MISSING_OR_NULL_ORIGIN: "Missing or null Origin",
|
||||
CALLBACK_URL_REQUIRED: "callbackURL is required",
|
||||
FAILED_TO_CREATE_VERIFICATION: "Unable to create verification",
|
||||
FIELD_NOT_ALLOWED: "Field not allowed to be set",
|
||||
ASYNC_VALIDATION_NOT_SUPPORTED: "Async validation is not supported",
|
||||
VALIDATION_ERROR: "Validation Error",
|
||||
MISSING_FIELD: "Field is required",
|
||||
METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED:
|
||||
"POST method requires deferSessionRefresh to be enabled in session config",
|
||||
BODY_MUST_BE_AN_OBJECT: "Body must be an object",
|
||||
PASSWORD_ALREADY_SET: "User already has a password set",
|
||||
Sign_up_is_disabled: "Sign up is disabled by the administrator",
|
||||
};
|
||||
|
||||
const match = Object.entries(errorMessages).find(([key]) =>
|
||||
error.includes(key),
|
||||
);
|
||||
|
||||
if (!match) return;
|
||||
|
||||
toast.error(match[1]);
|
||||
|
||||
const params = new URLSearchParams(url.toString());
|
||||
params.delete("error");
|
||||
const newUrl =
|
||||
window.location.pathname +
|
||||
(params.toString() ? `?${params.toString()}` : "");
|
||||
|
||||
window.history.replaceState({}, document.title, newUrl);
|
||||
}, [url]);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
+55
-12
@@ -1,26 +1,69 @@
|
||||
import {z} from "zod";
|
||||
import {SlackChannelConfigSchema} from "./providers/notifications/forms/slack.schema";
|
||||
import {SmtpChannelConfigSchema} from "./providers/notifications/forms/smtp.schema";
|
||||
import {DiscordChannelConfigSchema} from "./providers/notifications/forms/discord.schema";
|
||||
import {TelegramChannelConfigSchema} from "./providers/notifications/forms/telegram.schema";
|
||||
import {GotifyChannelConfigSchema} from "./providers/notifications/forms/gotify.schema";
|
||||
import {NtfyChannelConfigSchema} from "./providers/notifications/forms/ntfy.schema";
|
||||
import {WebhookChannelConfigSchema} from "./providers/notifications/forms/webhook.schema";
|
||||
import {S3ChannelConfigSchema} from "./providers/storages/forms/s3.schema";
|
||||
import {GoogleDriveChannelConfigSchema} from "./providers/storages/forms/google-drive.schema";
|
||||
import {LocalChannelConfigSchema} from "./providers/storages/forms/local.schema";
|
||||
|
||||
|
||||
export const ChannelFormSchema = z.object({
|
||||
const BaseChannelFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(5, "Name must be at least 5 characters long")
|
||||
.max(40, "Name must be at most 40 characters long"),
|
||||
config: z.record(z.union([z.string(), z.number(), z.boolean(), z.null(), z.undefined()])).optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const NotificationChannelFormSchema = z.discriminatedUnion("provider", [
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("slack"),
|
||||
config: SlackChannelConfigSchema,
|
||||
}),
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("smtp"),
|
||||
config: SmtpChannelConfigSchema,
|
||||
}),
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("discord"),
|
||||
config: DiscordChannelConfigSchema,
|
||||
}),
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("telegram"),
|
||||
config: TelegramChannelConfigSchema,
|
||||
}),
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("gotify"),
|
||||
config: GotifyChannelConfigSchema,
|
||||
}),
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("ntfy"),
|
||||
config: NtfyChannelConfigSchema,
|
||||
}),
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("webhook"),
|
||||
config: WebhookChannelConfigSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const NotificationChannelFormSchema = ChannelFormSchema.extend({
|
||||
provider: z.enum(
|
||||
["slack", "smtp", "discord", "telegram", "gotify", "ntfy", "webhook"],
|
||||
{required_error: "Provider is required"}
|
||||
),
|
||||
});
|
||||
|
||||
export const StorageChannelFormSchema = ChannelFormSchema.extend({
|
||||
provider: z.enum(["local", "s3", "google-drive"], {required_error: "Provider is required"}),
|
||||
});
|
||||
export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("s3"),
|
||||
config: S3ChannelConfigSchema,
|
||||
}),
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("google-drive"),
|
||||
config: GoogleDriveChannelConfigSchema,
|
||||
}),
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("local"),
|
||||
config: LocalChannelConfigSchema
|
||||
})
|
||||
]);
|
||||
|
||||
|
||||
export type NotificationChannelFormType = z.infer<typeof NotificationChannelFormSchema>;
|
||||
|
||||
+2
-1
@@ -51,7 +51,8 @@ export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestC
|
||||
toast.success("Successfully connected to storage channel");
|
||||
} else {
|
||||
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 {
|
||||
toast.error("Not yet supported");
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const DiscordChannelConfigSchema = z.object({
|
||||
discordWebhook: z.string().url("Must be a valid URL"),
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const GotifyChannelConfigSchema = z.object({
|
||||
gotifyServerUrl: z.string().url("Must be a valid URL"),
|
||||
gotifyAppToken: z.string().min(1, "App token is required"),
|
||||
});
|
||||
+2
-1
@@ -2,6 +2,7 @@ import {UseFormReturn} from "react-hook-form";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
|
||||
type NotifierNtfyFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
@@ -79,7 +80,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
||||
<FormItem>
|
||||
<FormLabel>Basic Auth Password (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" placeholder="password"/>
|
||||
<PasswordInput placeholder="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const NtfyChannelConfigSchema = z.object({
|
||||
ntfyTopic: z.string().min(1, "Topic is required"),
|
||||
ntfyServerUrl: z.string().url("Must be a valid URL").optional().or(z.literal('')),
|
||||
ntfyToken: z.string().optional(),
|
||||
ntfyUsername: z.string().optional(),
|
||||
ntfyPassword: z.string().optional(),
|
||||
});
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const SlackChannelConfigSchema = z.object({
|
||||
slackWebhook: z.string().url("Must be a valid URL"),
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const SmtpChannelConfigSchema = z.object({
|
||||
host: z.string().min(1, "Host is required"),
|
||||
port: z.coerce.number().min(1, "Port is required"),
|
||||
user: z.string().min(1, "User is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
from: z.string().min(1, "From is required"),
|
||||
to: z.string().min(1, "To is required"),
|
||||
});
|
||||
+16
@@ -43,6 +43,22 @@ export const NotifierTelegramForm = ({form}: NotifierTelegramFormProps) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.telegramTopicId"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram Topic ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="123456"/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Unique identifier for the target message thread (topic) of the forum; for forum supergroups only
|
||||
</p>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const TelegramChannelConfigSchema = z
|
||||
.object({
|
||||
telegramBotToken: z.string().min(1, "Bot token is required"),
|
||||
telegramChatId: z.string().min(1, "Chat ID is required"),
|
||||
telegramTopicId: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.telegramTopicId && !data.telegramChatId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Chat ID is required when a Topic ID is provided",
|
||||
path: ["telegramChatId"],
|
||||
},
|
||||
);
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const WebhookChannelConfigSchema = z.object({
|
||||
webhookUrl: z.string().url("Must be a valid URL"),
|
||||
webhookSecretHeader: z.string().optional(),
|
||||
webhookSecret: z.string().optional(),
|
||||
});
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const GoogleDriveChannelConfigSchema = z.object({
|
||||
clientId: z.string().min(1, "Client ID is required"),
|
||||
clientSecret: z.string().min(1, "Client Secret is required"),
|
||||
folderId: z.string().min(1, "Folder ID is required"),
|
||||
refreshToken: z.string().optional(),
|
||||
});
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const LocalChannelConfigSchema = z.object({});
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const S3ChannelConfigSchema = z.object({
|
||||
endPointUrl: z.string().min(1, "Endpoint URL is required"),
|
||||
region: z.string().optional(),
|
||||
accessKey: z.string().min(1, "Access Key is required"),
|
||||
secretKey: z.string().min(1, "Secret Key is required"),
|
||||
bucketName: z.string().min(1, "Bucket name is required"),
|
||||
port: z.coerce.number().optional(),
|
||||
ssl: z.boolean().optional().default(true),
|
||||
});
|
||||
@@ -1,60 +1,65 @@
|
||||
"use client"
|
||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {useState} from "react";
|
||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
import {ChannelCard} from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card";
|
||||
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
|
||||
import {ChannelKind, getChannelTextBasedOnKind} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
"use client";
|
||||
import { NotificationChannelWith } from "@/db/schema/09_notification-channel";
|
||||
import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination";
|
||||
import { useState } from "react";
|
||||
import { EmptyStatePlaceholder } from "@/components/wrappers/common/empty-state-placeholder";
|
||||
import { OrganizationWithMembers } from "@/db/schema/03_organization";
|
||||
import { StorageChannelWith } from "@/db/schema/12_storage-channel";
|
||||
import { ChannelCard } from "@/components/wrappers/dashboard/admin/channels/channel/channel-card/channel-card";
|
||||
import { ChannelAddEditModal } from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
|
||||
import {
|
||||
ChannelKind,
|
||||
getChannelTextBasedOnKind,
|
||||
} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
|
||||
type ChannelsSectionProps = {
|
||||
channels: NotificationChannelWith[] | StorageChannelWith[],
|
||||
organizations: OrganizationWithMembers[],
|
||||
kind: ChannelKind,
|
||||
defaultStorageChannelId?: string | null | undefined
|
||||
}
|
||||
channels: NotificationChannelWith[] | StorageChannelWith[];
|
||||
organizations: OrganizationWithMembers[];
|
||||
kind: ChannelKind;
|
||||
defaultStorageChannelId?: string | null | undefined;
|
||||
};
|
||||
|
||||
export const ChannelsSection = ({
|
||||
organizations,
|
||||
channels,
|
||||
kind,
|
||||
defaultStorageChannelId
|
||||
}: ChannelsSectionProps) => {
|
||||
organizations,
|
||||
channels,
|
||||
kind,
|
||||
defaultStorageChannelId,
|
||||
}: ChannelsSectionProps) => {
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const channelText = getChannelTextBasedOnKind(kind);
|
||||
const hasChannels = channels.length > 0;
|
||||
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const channelText = getChannelTextBasedOnKind(kind)
|
||||
const hasChannels = channels.length > 0
|
||||
|
||||
|
||||
return (
|
||||
return (
|
||||
<div className="h-full">
|
||||
<ChannelAddEditModal
|
||||
kind={kind}
|
||||
open={isAddModalOpen}
|
||||
onOpenChangeAction={setIsAddModalOpen}
|
||||
adminView={false}
|
||||
trigger={false}
|
||||
/>
|
||||
{hasChannels ? (
|
||||
<div className="h-full">
|
||||
<ChannelAddEditModal kind={kind} open={isAddModalOpen} onOpenChangeAction={setIsAddModalOpen}
|
||||
adminView={false}
|
||||
trigger={false}/>
|
||||
{hasChannels ? (
|
||||
<div className="h-full">
|
||||
<CardsWithPagination
|
||||
data={channels}
|
||||
cardItem={ChannelCard}
|
||||
cardsPerPage={8}
|
||||
numberOfColumns={2}
|
||||
adminView={true}
|
||||
organizations={organizations}
|
||||
kind={kind}
|
||||
defaultStorageChannelId={defaultStorageChannelId}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyStatePlaceholder
|
||||
text={`No ${channelText} channels configured yet`}
|
||||
onClick={() => {
|
||||
setIsAddModalOpen(true)
|
||||
}}
|
||||
className="h-full"
|
||||
/>
|
||||
)}
|
||||
<CardsWithPagination
|
||||
data={channels}
|
||||
cardItem={ChannelCard}
|
||||
cardsPerPage={8}
|
||||
numberOfColumns={2}
|
||||
adminView={true}
|
||||
organizations={organizations}
|
||||
kind={kind}
|
||||
defaultStorageChannelId={defaultStorageChannelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
) : (
|
||||
<EmptyStatePlaceholder
|
||||
text={`No ${channelText} channels configured yet`}
|
||||
onClick={() => {
|
||||
setIsAddModalOpen(true);
|
||||
}}
|
||||
className="h-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,75 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {useRouter} from "next/navigation";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { User } from "@/db/schema/02_user";
|
||||
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {toast} from "sonner";
|
||||
import {setSuperAdminOwnerOfOrganizationsOwnedByUser} from "@/components/wrappers/dashboard/admin/users/user.action";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { toast } from "sonner";
|
||||
import { setSuperAdminOwnerOfOrganizationsOwnedByUser } from "@/components/wrappers/dashboard/admin/users/user.action";
|
||||
|
||||
type AdminDeleteUserModalProps = {
|
||||
open: boolean;
|
||||
user: User;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
user: User;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const AdminDeleteUserModal = ({user, open, onOpenChange}: AdminDeleteUserModalProps) => {
|
||||
const router = useRouter();
|
||||
export const AdminDeleteUserModal = ({
|
||||
user,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AdminDeleteUserModalProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await setSuperAdminOwnerOfOrganizationsOwnedByUser({userId: user.id});
|
||||
const result = res?.data;
|
||||
if (result?.success) {
|
||||
await authClient.admin.removeUser(
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
onSuccess: async (response) => {
|
||||
toast.success(`User ${user.name} successfully deleted`);
|
||||
onOpenChange(false);
|
||||
router.refresh();
|
||||
},
|
||||
onError: async (error) => {
|
||||
toast.error("An error has occurred while deleting user");
|
||||
onOpenChange(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.error("An error has occurred while deleting user");
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await setSuperAdminOwnerOfOrganizationsOwnedByUser({
|
||||
userId: user.id,
|
||||
});
|
||||
const result = res?.data;
|
||||
if (result?.success) {
|
||||
await authClient.admin.removeUser(
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
toast.success(`User ${user.name} successfully deleted`);
|
||||
onOpenChange(false);
|
||||
router.refresh();
|
||||
},
|
||||
onError: async (error) => {
|
||||
toast.error("An error has occurred while deleting user");
|
||||
onOpenChange(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error("An error has occurred while deleting user");
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure you want to delete {user.name} ?</AlertDialogTitle>
|
||||
<AlertDialogDescription>This action is irreversible: it will lead to the deletion of the user's
|
||||
data.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<ButtonWithLoading
|
||||
onClick={async () => await mutation.mutateAsync()}>Confirm</ButtonWithLoading>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure you want to delete {user.name} ?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action is irreversible: it will lead to the deletion of the
|
||||
user's data.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<ButtonWithLoading onClick={async () => await mutation.mutateAsync()}>
|
||||
Confirm
|
||||
</ButtonWithLoading>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,8 +5,9 @@ import {User} from "@/db/schema/02_user";
|
||||
|
||||
type AdminUserListProps = {
|
||||
users: User[];
|
||||
isPasswordAuthEnabled: boolean;
|
||||
};
|
||||
|
||||
export const AdminUserList = ({ users }: AdminUserListProps) => {
|
||||
return <DataTable columns={usersListColumns()} data={users} enablePagination={true} enableSelect={false} />;
|
||||
export const AdminUserList = ({ users, isPasswordAuthEnabled }: AdminUserListProps) => {
|
||||
return <DataTable columns={usersListColumns({ isPasswordAuthEnabled })} data={users} enablePagination={true} enableSelect={false} />;
|
||||
};
|
||||
|
||||
@@ -9,7 +9,11 @@ import {Info} from "lucide-react";
|
||||
import {Table, TableBody, TableCell, TableRow} from "@/components/ui/table";
|
||||
import {UserActionsCell} from "@/components/wrappers/dashboard/admin/users/user-actions-cell";
|
||||
|
||||
export function usersListColumns(): ColumnDef<User>[] {
|
||||
type UsersListColumnsProps = {
|
||||
isPasswordAuthEnabled: boolean;
|
||||
}
|
||||
|
||||
export function usersListColumns({ isPasswordAuthEnabled }: UsersListColumnsProps): ColumnDef<User>[] {
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -85,7 +89,7 @@ export function usersListColumns(): ColumnDef<User>[] {
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({row}) => <UserActionsCell user={row.original}/>,
|
||||
cell: ({row}) => <UserActionsCell user={row.original} isPasswordAuthEnabled={isPasswordAuthEnabled} />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -20,9 +20,10 @@ import {AdminDeleteUserModal} from "@/components/wrappers/dashboard/admin/users/
|
||||
|
||||
interface UserActionsCellProps {
|
||||
user: User;
|
||||
isPasswordAuthEnabled: boolean;
|
||||
}
|
||||
|
||||
export function UserActionsCell({user}: UserActionsCellProps) {
|
||||
export function UserActionsCell({user, isPasswordAuthEnabled}: UserActionsCellProps) {
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isModalChangePasswordOpen, setIsModalChangePasswordOpen] = useState(false);
|
||||
@@ -43,9 +44,11 @@ export function UserActionsCell({user}: UserActionsCellProps) {
|
||||
onOpenChange={setIsModalChangePasswordOpen}/>
|
||||
<AdminUserEdit user={user} open={isModalEditUserOpen} onOpenChange={setIsModalEditUserOpen}/>
|
||||
<div className={cn("flex items-center space-x-2")}>
|
||||
<Button variant="outline" size="icon" onClick={() => setIsModalChangePasswordOpen(true)}>
|
||||
<RotateCcwKey className="w-4 h-4"/>
|
||||
</Button>
|
||||
{isPasswordAuthEnabled && (
|
||||
<Button variant="outline" size="icon" onClick={() => setIsModalChangePasswordOpen(true)}>
|
||||
<RotateCcwKey className="w-4 h-4"/>
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
|
||||
@@ -1,191 +1,211 @@
|
||||
"use server";
|
||||
|
||||
import * as drizzleDb from "@/db";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {render} from "@react-email/render";
|
||||
import {UserSchema} from "@/components/wrappers/dashboard/admin/users/user.schema";
|
||||
import {extractNameFromEmail} from "@/utils/name-from-email";
|
||||
import {generateValidPassword} from "@/utils/password";
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {z} from "zod";
|
||||
import {Organization} from "@/db/schema/03_organization";
|
||||
import {db} from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import { ServerActionResult } from "@/types/action-type";
|
||||
import { render } from "@react-email/render";
|
||||
import { UserSchema } from "@/components/wrappers/dashboard/admin/users/user.schema";
|
||||
import { extractNameFromEmail } from "@/utils/name-from-email";
|
||||
import { generateValidPassword } from "@/utils/password";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { z } from "zod";
|
||||
import { Organization } from "@/db/schema/03_organization";
|
||||
import { db } from "@/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
import {zEmail, zString} from "@/lib/zod";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {
|
||||
addMemberOrganizationAction
|
||||
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/add-member.action";
|
||||
import {sendEmail} from "@/lib/email";
|
||||
import { zEmail, zString } from "@/lib/zod";
|
||||
import { withUpdatedAt } from "@/db/utils";
|
||||
import { userAction } from "@/lib/safe-actions/actions";
|
||||
import { addMemberOrganizationAction } from "@/components/wrappers/dashboard/admin/organizations/organization/details/add-member.action";
|
||||
import { sendEmail } from "@/lib/email";
|
||||
import EmailCreateUser from "@/components/emails/email-create-user";
|
||||
import {SignUpUser} from "@/types/auth";
|
||||
import {createUserDb} from "@/db/services/user";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
import { SignUpUser } from "@/types/auth";
|
||||
import { createUserDb } from "@/db/services/user";
|
||||
import { User } from "@/db/schema/02_user";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
export const createUserAction = userAction.schema(UserSchema).action(async ({parsedInput}): Promise<ServerActionResult<User>> => {
|
||||
export const createUserAction = userAction
|
||||
.schema(UserSchema)
|
||||
.action(async ({ parsedInput }): Promise<ServerActionResult<User>> => {
|
||||
try {
|
||||
const password = generateValidPassword();
|
||||
const isPasswordAuthEnabled = env.AUTH_EMAIL_PASSWORD_ENABLED === "true";
|
||||
let password;
|
||||
|
||||
const userData: SignUpUser = {
|
||||
name: parsedInput.name || extractNameFromEmail(parsedInput.email),
|
||||
email: parsedInput.email,
|
||||
password: password,
|
||||
theme: "dark",
|
||||
role: "user",
|
||||
};
|
||||
const userData: SignUpUser = {
|
||||
name: parsedInput.name || extractNameFromEmail(parsedInput.email),
|
||||
email: parsedInput.email,
|
||||
theme: "dark",
|
||||
role: "user",
|
||||
};
|
||||
|
||||
const newUser = await createUserDb(userData);
|
||||
if (isPasswordAuthEnabled) {
|
||||
password = generateValidPassword();
|
||||
userData.password = password;
|
||||
}
|
||||
|
||||
if (newUser) {
|
||||
const newUser = await createUserDb(userData);
|
||||
|
||||
await sendEmail({
|
||||
to: parsedInput.email,
|
||||
subject: "Your account is created",
|
||||
html: await render(EmailCreateUser({
|
||||
password: password,
|
||||
email: parsedInput.email,
|
||||
})),
|
||||
});
|
||||
if (newUser) {
|
||||
await sendEmail({
|
||||
to: parsedInput.email,
|
||||
subject: "Your account is created",
|
||||
html: await render(
|
||||
EmailCreateUser({
|
||||
password: password,
|
||||
email: parsedInput.email,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const defaultOrganization = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.slug, "default"),
|
||||
});
|
||||
const defaultOrganization = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.slug, "default"),
|
||||
});
|
||||
|
||||
if (defaultOrganization) {
|
||||
await auth.api.addMember({
|
||||
body: {
|
||||
userId: newUser.id,
|
||||
organizationId: defaultOrganization.id,
|
||||
role: "admin",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: newUser,
|
||||
actionSuccess: {
|
||||
message: "user_created",
|
||||
},
|
||||
};
|
||||
if (defaultOrganization) {
|
||||
await auth.api.addMember({
|
||||
body: {
|
||||
userId: newUser.id,
|
||||
organizationId: defaultOrganization.id,
|
||||
role: "admin",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "user_created",
|
||||
cause: "Unknown error",
|
||||
},
|
||||
success: true,
|
||||
value: newUser,
|
||||
actionSuccess: {
|
||||
message: "user_created",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "user_created",
|
||||
cause: "Unknown error",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "user_created",
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "user_created",
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const updateUserAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
id: zString(),
|
||||
name: zString().optional(),
|
||||
email: zEmail(),
|
||||
})
|
||||
)
|
||||
.action(async ({parsedInput, ctx}): Promise<ServerActionResult<{}>> => {
|
||||
try {
|
||||
.schema(
|
||||
z.object({
|
||||
id: zString(),
|
||||
name: zString().optional(),
|
||||
email: zEmail(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput, ctx }): Promise<ServerActionResult<{}>> => {
|
||||
try {
|
||||
const [updatedUser] = await db
|
||||
.update(drizzleDb.schemas.user)
|
||||
.set(
|
||||
withUpdatedAt({
|
||||
name: parsedInput.name
|
||||
? parsedInput.name
|
||||
: extractNameFromEmail(parsedInput.email),
|
||||
email: parsedInput.email,
|
||||
emailVerified: false,
|
||||
}),
|
||||
)
|
||||
.where(eq(drizzleDb.schemas.user.id, parsedInput.id))
|
||||
.returning();
|
||||
|
||||
if (updatedUser) {
|
||||
return {
|
||||
success: true,
|
||||
actionSuccess: {
|
||||
message: "user_updated",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const [updatedUser] = await db.update(drizzleDb.schemas.user).set(withUpdatedAt({
|
||||
name: parsedInput.name ? parsedInput.name : extractNameFromEmail(parsedInput.email),
|
||||
email: parsedInput.email,
|
||||
emailVerified: false
|
||||
})).where(eq(drizzleDb.schemas.user.id, parsedInput.id)).returning();
|
||||
|
||||
|
||||
if (updatedUser) {
|
||||
return {
|
||||
success: true,
|
||||
actionSuccess: {
|
||||
message: "user_updated",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "user_updated",
|
||||
cause: "Unknown error",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "user_updated",
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "user_updated",
|
||||
cause: "Unknown error",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "user_updated",
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const setSuperAdminOwnerOfOrganizationsOwnedByUser = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
})
|
||||
)
|
||||
.action(async ({parsedInput}): Promise<ServerActionResult<Organization[]>> => {
|
||||
try {
|
||||
const organizationsWhereUserIsMemberAndOwner = await db.query.member.findMany({
|
||||
where: and(eq(drizzleDb.schemas.member.role, "owner"), eq(drizzleDb.schemas.member.userId, parsedInput.userId)),
|
||||
with: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
.schema(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
}),
|
||||
)
|
||||
.action(
|
||||
async ({ parsedInput }): Promise<ServerActionResult<Organization[]>> => {
|
||||
try {
|
||||
const organizationsWhereUserIsMemberAndOwner =
|
||||
await db.query.member.findMany({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.member.role, "owner"),
|
||||
eq(drizzleDb.schemas.member.userId, parsedInput.userId),
|
||||
),
|
||||
with: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
const superAdminUser = await db.query.user.findFirst();
|
||||
if (!superAdminUser) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "set_super_admin_owner_of_organizations_owned_by_user",
|
||||
cause: "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (let {organization} of organizationsWhereUserIsMemberAndOwner) {
|
||||
await addMemberOrganizationAction({
|
||||
userId: superAdminUser.id,
|
||||
organizationId: organization.id,
|
||||
role: "owner",
|
||||
});
|
||||
}
|
||||
const organizations = organizationsWhereUserIsMemberAndOwner.map(
|
||||
(organizationWhereUserIsMemberAndOwner) => organizationWhereUserIsMemberAndOwner.organization
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: organizations as unknown as Organization[],
|
||||
actionSuccess: {
|
||||
message: "set_super_admin_owner_of_organizations_owned_by_user",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "error_set_super_admin_owner_of_organizations_owned_by_user",
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
const superAdminUser = await db.query.user.findFirst();
|
||||
if (!superAdminUser) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "set_super_admin_owner_of_organizations_owned_by_user",
|
||||
cause: "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
for (let { organization } of organizationsWhereUserIsMemberAndOwner) {
|
||||
await addMemberOrganizationAction({
|
||||
userId: superAdminUser.id,
|
||||
organizationId: organization.id,
|
||||
role: "owner",
|
||||
});
|
||||
}
|
||||
const organizations = organizationsWhereUserIsMemberAndOwner.map(
|
||||
(organizationWhereUserIsMemberAndOwner) =>
|
||||
organizationWhereUserIsMemberAndOwner.organization,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: organizations as unknown as Organization[],
|
||||
actionSuccess: {
|
||||
message: "set_super_admin_owner_of_organizations_owned_by_user",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message:
|
||||
"error_set_super_admin_owner_of_organizations_owned_by_user",
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,49 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {toast} from "sonner";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {backupButtonAction} from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {Database, DatabaseZap} from "lucide-react";
|
||||
import {useIsMobile} from "@/hooks/use-mobile";
|
||||
import { backupButtonAction } from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
|
||||
import { Check, DatabaseZap, X } from "lucide-react";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
|
||||
|
||||
export type BackupButtonProps = {
|
||||
databaseId: string;
|
||||
disable: boolean;
|
||||
databaseId: string;
|
||||
disable: boolean;
|
||||
};
|
||||
|
||||
export const BackupButton = (props: BackupButtonProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const isMobile = useIsMobile()
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (databaseId: string) => {
|
||||
const backup = await backupButtonAction(databaseId);
|
||||
if (backup?.data?.success) {
|
||||
toast.success(backup.data.actionSuccess?.message || "Backup created successfully!");
|
||||
queryClient.invalidateQueries({queryKey: ["database-data", props.databaseId]});
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(backup?.serverError || "Failed to create backup.");
|
||||
}
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (databaseId: string) => {
|
||||
const backup = await backupButtonAction(databaseId);
|
||||
if (backup?.data?.success) {
|
||||
toast.success(
|
||||
backup.data.actionSuccess?.message || "Backup created successfully!",
|
||||
);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["database-data", props.databaseId],
|
||||
});
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(backup?.serverError || "Failed to create backup.");
|
||||
}
|
||||
},
|
||||
});
|
||||
const HandleAction = async () => {
|
||||
await mutation.mutateAsync(props.databaseId);
|
||||
};
|
||||
|
||||
return (
|
||||
<ButtonWithConfirm
|
||||
title="Create Backup"
|
||||
description={"Are you sure you want to create a backup?"}
|
||||
button={{
|
||||
main: {
|
||||
disabled: props.disable,
|
||||
text: isMobile ? "" : "Backup",
|
||||
variant: "default",
|
||||
icon: <DatabaseZap />,
|
||||
},
|
||||
});
|
||||
const HandleAction = async () => {
|
||||
await mutation.mutateAsync(props.databaseId);
|
||||
};
|
||||
|
||||
return (
|
||||
<ButtonWithLoading
|
||||
icon={<DatabaseZap/>}
|
||||
disabled={props.disable}
|
||||
isPending={mutation.isPending}
|
||||
size={"default"}
|
||||
onClick={async () => {
|
||||
await HandleAction();
|
||||
}}
|
||||
>{isMobile ? "" : "Backup"}</ButtonWithLoading>
|
||||
);
|
||||
confirm: {
|
||||
className: "w-full",
|
||||
text: "Yes, create backup",
|
||||
icon: <Check />,
|
||||
variant: "default",
|
||||
onClick: async () => {
|
||||
await HandleAction();
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
className: "w-full",
|
||||
text: "No, cancel",
|
||||
icon: <X />,
|
||||
variant: "outline",
|
||||
},
|
||||
}}
|
||||
isPending={mutation.isPending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {getAccounts, getSession, getSessions} from "@/lib/auth/auth";
|
||||
import {LoggedInButtonClient} from "./logged-in-button";
|
||||
import {SUPPORTED_PROVIDERS} from "../../../../../../portabase.config";
|
||||
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { getAccounts, getSession, getSessions } from "@/lib/auth/auth";
|
||||
import { LoggedInButtonClient } from "./logged-in-button";
|
||||
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
|
||||
|
||||
export const LoggedInButton = async () => {
|
||||
const user = await currentUser();
|
||||
@@ -10,7 +9,6 @@ export const LoggedInButton = async () => {
|
||||
const currentSession = await getSession();
|
||||
const accounts = await getAccounts();
|
||||
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,29 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import {ChevronsUpDown} from "lucide-react";
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
||||
import {SidebarMenuButton} from "@/components/ui/sidebar";
|
||||
import {LoggedInDropdown} from "./logged-in-dropdown";
|
||||
import {Account, Session, User} from "better-auth";
|
||||
import {AuthProviderConfig} from "../../../../../../portabase.config";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { SidebarMenuButton } from "@/components/ui/sidebar";
|
||||
import { LoggedInDropdown } from "./logged-in-dropdown";
|
||||
import { Account, Session, User } from "better-auth";
|
||||
import { AuthProviderConfig } from "@/lib/auth/config";
|
||||
|
||||
type LoggedInButtonClientProps = {
|
||||
user: User,
|
||||
sessions: Session[],
|
||||
currentSession: Session,
|
||||
accounts: Account[],
|
||||
providers: AuthProviderConfig[]
|
||||
}
|
||||
|
||||
|
||||
export const LoggedInButtonClient = ({
|
||||
user,
|
||||
sessions,
|
||||
currentSession,
|
||||
accounts,
|
||||
providers
|
||||
}: LoggedInButtonClientProps) => {
|
||||
user: User;
|
||||
sessions: Session[];
|
||||
currentSession: Session;
|
||||
accounts: Account[];
|
||||
providers: AuthProviderConfig[];
|
||||
};
|
||||
|
||||
export const LoggedInButtonClient = ({ user, sessions, currentSession, accounts, providers }: LoggedInButtonClientProps) => {
|
||||
return (
|
||||
<LoggedInDropdown
|
||||
// @ts-ignore
|
||||
@@ -40,20 +32,16 @@ export const LoggedInButtonClient = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="size-6">
|
||||
<AvatarFallback>{user.name[0].toUpperCase()}</AvatarFallback>
|
||||
{user.image && <AvatarImage src={user.image}/>}
|
||||
{user.image && <AvatarImage src={user.image} />}
|
||||
</Avatar>
|
||||
<div className="flex flex-col items-start">
|
||||
<span
|
||||
className="text-sm font-medium first-letter:capitalize max-w-[170px] truncate">{user.name}</span>
|
||||
<span
|
||||
className="text-xs text-muted-foreground max-w-[170px] truncate"
|
||||
title={user.email}
|
||||
>
|
||||
{user.email}
|
||||
</span>
|
||||
<span className="text-sm font-medium first-letter:capitalize max-w-[170px] truncate">{user.name}</span>
|
||||
<span className="text-xs text-muted-foreground max-w-[170px] truncate" title={user.email}>
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50"/>
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</SidebarMenuButton>
|
||||
</LoggedInDropdown>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {PropsWithChildren, ReactNode, useState} from "react";
|
||||
import { PropsWithChildren, ReactNode, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -10,11 +10,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSepara
|
||||
|
||||
import { signOut } from "@/lib/auth/auth-client";
|
||||
|
||||
import {ProfileModal} from "@/components/wrappers/dashboard/common/profile/profile-modal";
|
||||
import { ProfileModal } from "@/components/wrappers/dashboard/common/profile/profile-modal";
|
||||
|
||||
import {Account, Session, User as UserType} from "@/db/schema/02_user";
|
||||
import { Account, Session, User as UserType } from "@/db/schema/02_user";
|
||||
|
||||
import {AuthProviderConfig} from "../../../../../../portabase.config";
|
||||
import { AuthProviderConfig } from "@/lib/auth/config";
|
||||
|
||||
export type LoggedInDropdownProps = PropsWithChildren<{
|
||||
user: UserType;
|
||||
@@ -22,13 +22,10 @@ export type LoggedInDropdownProps = PropsWithChildren<{
|
||||
currentSession: Session;
|
||||
accounts: Account[];
|
||||
children: ReactNode;
|
||||
providers: AuthProviderConfig[]
|
||||
providers: AuthProviderConfig[];
|
||||
}>;
|
||||
|
||||
|
||||
|
||||
export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers }: LoggedInDropdownProps) => {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -44,50 +41,46 @@ export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, chi
|
||||
onOpenChange={setIsModalOpen}
|
||||
providers={providers}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none p-1"
|
||||
align="start"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setIsModalOpen(!isModalOpen)}
|
||||
className="group gap-2 p-1 cursor-pointer rounded-lg mb-1 transition-colors focus:bg-accent hover:bg-accent/50 border border-transparent"
|
||||
>
|
||||
<div className="flex size-9 items-center justify-center rounded-md border border-border bg-muted/50 shadow-sm transition-all group-hover:shadow-md group-hover:bg-background">
|
||||
<User size={18} className="text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium leading-none">Account Settings</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="group gap-2 p-1 cursor-pointer rounded-lg transition-colors focus:bg-red-50 dark:focus:bg-red-950/20 border border-transparent text-red-600 focus:text-red-600"
|
||||
onClick={async () => {
|
||||
await signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
router.push("/login");
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex size-9 items-center justify-center rounded-md border border-red-100 bg-red-50/50 dark:border-red-900/30 dark:bg-red-950/20 shadow-sm transition-all group-hover:shadow-md">
|
||||
<LogOut size={18} className="text-red-500" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium leading-none">Logout</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none p-1"
|
||||
align="start"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setIsModalOpen(!isModalOpen)}
|
||||
className="group gap-2 p-1 cursor-pointer rounded-lg mb-1 transition-colors focus:bg-accent hover:bg-accent/50 border border-transparent"
|
||||
>
|
||||
<div className="flex size-9 items-center justify-center rounded-md border border-border bg-muted/50 shadow-sm transition-all group-hover:shadow-md group-hover:bg-background">
|
||||
<User size={18} className="text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium leading-none">Account Settings</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="group gap-2 p-1 cursor-pointer rounded-lg transition-colors focus:bg-red-50 dark:focus:bg-red-950/20 border border-transparent text-red-600 focus:text-red-600"
|
||||
onClick={async () => {
|
||||
await signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
router.push("/login");
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex size-9 items-center justify-center rounded-md border border-red-100 bg-red-50/50 dark:border-red-900/30 dark:bg-red-950/20 shadow-sm transition-all group-hover:shadow-md">
|
||||
<LogOut size={18} className="text-red-500" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium leading-none">Logout</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import { Account, Session, User } from "@/db/schema/02_user";
|
||||
import { ProfileSidebar } from "./profile-sidebar";
|
||||
import {ProfileProviders} from "@/components/wrappers/dashboard/profile/profile-providers";
|
||||
import {ProfileAccount} from "@/components/wrappers/dashboard/profile/profile-account";
|
||||
import {ProfileAppearance} from "@/components/wrappers/dashboard/profile/profile-apperance";
|
||||
import {ProfileSecurity} from "@/components/wrappers/dashboard/profile/profile-security";
|
||||
import {ProfileGeneral} from "@/components/wrappers/dashboard/profile/profile-general";
|
||||
import {AuthProviderConfig} from "../../../../../../portabase.config";
|
||||
import type { AuthProviderConfig } from "@/lib/auth/config";
|
||||
import { User, Session, Account } from "@/db/schema/02_user";
|
||||
import { ProfileGeneral } from "../../profile/profile-general";
|
||||
import { ProfileSecurity } from "../../profile/profile-security";
|
||||
import { ProfileProviders } from "../../profile/profile-providers";
|
||||
import { ProfileAccount } from "../../profile/profile-account";
|
||||
import { ProfileAppearance } from "../../profile/profile-apperance";
|
||||
|
||||
type ProfileModalProps = {
|
||||
open: boolean;
|
||||
@@ -19,14 +18,13 @@ type ProfileModalProps = {
|
||||
currentSession: Session;
|
||||
accounts: Account[];
|
||||
onOpenChange: (open: boolean) => void;
|
||||
providers: AuthProviderConfig[]
|
||||
|
||||
providers: AuthProviderConfig[];
|
||||
};
|
||||
|
||||
export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers }: ProfileModalProps) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] pb-6 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background">
|
||||
<DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] pb-6 p-0 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
<DialogDescription>Manage your account settings</DialogDescription>
|
||||
@@ -45,6 +43,9 @@ export const ProfileModal = ({ user, sessions, currentSession, accounts, open, o
|
||||
sessions={sessions}
|
||||
currentSession={currentSession}
|
||||
credentialAccount={accounts.find((acc) => acc.providerId === "credential")!}
|
||||
isPasswordEnabled={providers.some((p) => p.id === "credential")}
|
||||
isPasskeyEnabled={providers.some((p) => p.id === "passkey")}
|
||||
providers={providers}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { use } from "react";
|
||||
import React from "react";
|
||||
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { UserIcon, Settings, Palette, ShieldHalf, Workflow } from "lucide-react";
|
||||
import { User } from "@/db/schema/02_user";
|
||||
@@ -10,7 +10,6 @@ interface ProfileSidebarProps {
|
||||
}
|
||||
|
||||
export function ProfileSidebar({ user }: ProfileSidebarProps) {
|
||||
|
||||
return (
|
||||
<div className="w-full lg:w-[260px] flex-shrink-0 lg:border-r bg-muted/10 p-4 lg:p-6 flex flex-col gap-4 border-b lg:border-b-0">
|
||||
<div className="flex items-center px-2 mb-2">
|
||||
|
||||
+272
-231
@@ -1,253 +1,294 @@
|
||||
"use client"
|
||||
import {Backup, BackupWith, Restoration} from "@/db/schema/07_database";
|
||||
import {Swiper, SwiperSlide} from "swiper/react";
|
||||
//@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 {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
"use client";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 {getChannelIcon} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {getStatusColor, getStatusIcon} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
|
||||
import {useRouter} from "next/navigation";
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useZodForm,
|
||||
} from "@/components/ui/form";
|
||||
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 {
|
||||
createRestorationBackupAction, deleteBackupAction, deleteBackupStorageAction,
|
||||
downloadBackupAction
|
||||
getStatusColor,
|
||||
getStatusIcon,
|
||||
} from "@/components/wrappers/dashboard/admin/notifications/logs/columns";
|
||||
import {
|
||||
createRestorationBackupAction,
|
||||
deleteBackupAction,
|
||||
deleteBackupStorageAction,
|
||||
downloadBackupAction,
|
||||
} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions.action";
|
||||
import {toast} from "sonner";
|
||||
import {SafeActionResult} from "next-safe-action";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {ZodString} from "zod";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
|
||||
import {AlertCircleIcon} from "lucide-react";
|
||||
import {
|
||||
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 { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
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 = {
|
||||
backup: BackupWith;
|
||||
action: DatabaseActionKind;
|
||||
}
|
||||
backup: BackupWith;
|
||||
action: DatabaseActionKind;
|
||||
};
|
||||
|
||||
export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
|
||||
export const BackupActionsForm = ({
|
||||
backup,
|
||||
action,
|
||||
}: BackupActionsFormProps) => {
|
||||
const filteredBackupStorages =
|
||||
backup.storages?.filter((storage) => storage.deletedAt === null) ?? [];
|
||||
const { closeModal } = useBackupModal();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
|
||||
const filteredBackupStorages = backup.storages?.filter((storage) => storage.deletedAt === null) ?? []
|
||||
const {closeModal} = useBackupModal();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const form = useZodForm({
|
||||
schema: BackupActionsSchema,
|
||||
});
|
||||
|
||||
const form = useZodForm({
|
||||
schema: BackupActionsSchema,
|
||||
});
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: BackupActionsType) => {
|
||||
let result:
|
||||
| SafeActionResult<
|
||||
string,
|
||||
ZodString,
|
||||
readonly [],
|
||||
{
|
||||
_errors?: string[] | undefined;
|
||||
},
|
||||
readonly [],
|
||||
ServerActionResult<string | Restoration | Backup>,
|
||||
object
|
||||
>
|
||||
| undefined;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: BackupActionsType) => {
|
||||
if (action === "download") {
|
||||
result = await downloadBackupAction({
|
||||
backupStorageId: values.backupStorageId,
|
||||
});
|
||||
} else if (action === "restore") {
|
||||
result = await createRestorationBackupAction({
|
||||
databaseId: backup.databaseId,
|
||||
backupStorageId: values.backupStorageId,
|
||||
backupId: backup.id,
|
||||
});
|
||||
} else if (action === "delete") {
|
||||
result = await deleteBackupStorageAction({
|
||||
databaseId: backup.databaseId,
|
||||
backupStorageId: values.backupStorageId,
|
||||
backupId: backup.id,
|
||||
});
|
||||
}
|
||||
|
||||
let result: SafeActionResult<string, ZodString, readonly [], {
|
||||
_errors?: string[] | undefined;
|
||||
}, readonly [], ServerActionResult<string | Restoration | Backup>, object> | undefined
|
||||
const inner = result?.data;
|
||||
|
||||
if (action === "download") {
|
||||
result = await downloadBackupAction({backupStorageId: values.backupStorageId})
|
||||
} else if (action === "restore") {
|
||||
result = await createRestorationBackupAction({
|
||||
databaseId: backup.databaseId,
|
||||
backupStorageId: values.backupStorageId,
|
||||
backupId: backup.id
|
||||
})
|
||||
} else if (action === "delete") {
|
||||
result = await deleteBackupStorageAction({
|
||||
databaseId: backup.databaseId,
|
||||
backupStorageId: values.backupStorageId,
|
||||
backupId: backup.id,
|
||||
})
|
||||
}
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["database-data", backup.databaseId],
|
||||
});
|
||||
router.refresh();
|
||||
if (action === "download") {
|
||||
const url = inner.value;
|
||||
if (typeof url === "string") {
|
||||
window.open(url, "_self");
|
||||
}
|
||||
closeModal();
|
||||
} else if (action === "restore") {
|
||||
closeModal();
|
||||
} else if (action === "delete") {
|
||||
closeModal();
|
||||
} else {
|
||||
closeModal();
|
||||
}
|
||||
} else {
|
||||
if (action === "delete") {
|
||||
toast.success("Backup deleted successfully.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["database-data", backup.databaseId],
|
||||
});
|
||||
router.refresh();
|
||||
closeModal();
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message ?? "An error occurred.");
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const inner = result?.data;
|
||||
const mutationDeleteEntireBackup = useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await deleteBackupAction({
|
||||
databaseId: backup.databaseId,
|
||||
backupId: backup.id,
|
||||
});
|
||||
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
|
||||
router.refresh();
|
||||
if (action === "download") {
|
||||
const url = inner.value
|
||||
if (typeof url === "string") {
|
||||
window.open(url, "_self");
|
||||
}
|
||||
closeModal()
|
||||
} else if (action === "restore") {
|
||||
closeModal()
|
||||
} else if (action === "delete") {
|
||||
closeModal()
|
||||
} else {
|
||||
closeModal()
|
||||
}
|
||||
} else {
|
||||
if (action === "delete") {
|
||||
toast.success("Backup deleted successfully.")
|
||||
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
|
||||
router.refresh();
|
||||
closeModal()
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message ?? "An error occurred.");
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
const inner = result?.data;
|
||||
|
||||
const mutationDeleteEntireBackup = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["database-data", backup.databaseId],
|
||||
});
|
||||
router.refresh();
|
||||
closeModal();
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const result = await deleteBackupAction({
|
||||
databaseId: backup.databaseId,
|
||||
backupId: backup.id,
|
||||
})
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4 mb-1 min-w-0"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
{filteredBackupStorages.length > 0 ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="backupStorageId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Choose a storage backup</FormLabel>
|
||||
<FormControl>
|
||||
<div
|
||||
className="flex flex-col gap-2.5 overflow-y-auto overflow-x-hidden w-full pr-1"
|
||||
style={{ maxHeight: "250px" }}
|
||||
>
|
||||
{filteredBackupStorages.map(
|
||||
(storage: BackupStorageWith) => (
|
||||
<button
|
||||
key={storage.id}
|
||||
disabled={
|
||||
action !== "delete" &&
|
||||
storage.status.toLowerCase() !== "success"
|
||||
}
|
||||
type="button"
|
||||
onClick={() => field.onChange(storage.id)}
|
||||
className={`w-full flex items-center gap-3 p-4 rounded-lg border text-left transition-colors
|
||||
${
|
||||
field.value === storage.id
|
||||
? "border-foreground bg-background"
|
||||
: "border-border bg-background" +
|
||||
(storage.status.toLowerCase() === "success" ||
|
||||
action === "delete"
|
||||
? " hover:border-muted-foreground"
|
||||
: "")
|
||||
}
|
||||
${
|
||||
storage.status.toLowerCase() !== "success" &&
|
||||
action !== "delete"
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`h-4 w-4 shrink-0 rounded-full border ${
|
||||
field.value === storage.id
|
||||
? "border-foreground"
|
||||
: "border-muted-foreground"
|
||||
} flex items-center justify-center`}
|
||||
>
|
||||
{field.value === storage.id && (
|
||||
<div className="h-2 w-2 rounded-full bg-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
const inner = result?.data;
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<div className="shrink-0">
|
||||
{getChannelIcon(
|
||||
storage.storageChannel?.provider || "",
|
||||
)}
|
||||
</div>
|
||||
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
queryClient.invalidateQueries({queryKey: ["database-data", backup.databaseId]});
|
||||
router.refresh();
|
||||
closeModal()
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
<h3 className="font-medium text-foreground truncate flex-1">
|
||||
{storage.storageChannel?.name}
|
||||
</h3>
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
{storage.storageChannel?.provider && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs font-mono shrink-0"
|
||||
>
|
||||
{storage.storageChannel.provider}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`gap-1.5 shrink-0 ${getStatusColor(storage.status)}`}
|
||||
>
|
||||
{getStatusIcon(storage.status === "success")}
|
||||
<span className="capitalize hidden sm:inline">
|
||||
{storage.status.toUpperCase()}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
),
|
||||
) ?? <p>No storages available</p>}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertCircleIcon />
|
||||
<AlertTitle>Backup does not have files</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
You can safely delete the entire backup; no files seem to be
|
||||
related. Maybe an error occurred.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4 mb-1"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
<div className="flex flex-row items-center gap-x-4 w-full">
|
||||
{action === "delete" && (
|
||||
<ButtonWithLoading
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => mutationDeleteEntireBackup.mutateAsync()}
|
||||
isPending={mutationDeleteEntireBackup.isPending}
|
||||
disabled={mutationDeleteEntireBackup.isPending}
|
||||
>
|
||||
Delete entire backup
|
||||
</ButtonWithLoading>
|
||||
)}
|
||||
|
||||
{filteredBackupStorages.length > 0 ?
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="backupStorageId"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Choose a storage backup</FormLabel>
|
||||
<FormControl>
|
||||
|
||||
<div style={{height: "250px"}}>
|
||||
<Swiper
|
||||
direction="vertical"
|
||||
slidesPerView={3.5}
|
||||
spaceBetween={10}
|
||||
// pagination={{ clickable: true }}
|
||||
mousewheel={{releaseOnEdges: true, forceToAxis: true}}
|
||||
modules={[Pagination, Mousewheel]}
|
||||
className="mySwiper"
|
||||
style={{height: "100%"}}
|
||||
>
|
||||
{filteredBackupStorages.map((storage: BackupStorageWith) => (
|
||||
<SwiperSlide key={storage.id}>
|
||||
<button
|
||||
disabled={action !== "delete" && storage.status.toLowerCase() !== "success"}
|
||||
type="button"
|
||||
onClick={() => field.onChange(storage.id)}
|
||||
className={`w-full h-full flex items-start gap-3 p-4 rounded-lg border text-left transition-colors
|
||||
${field.value === storage.id
|
||||
? "border-foreground bg-background"
|
||||
: "border-border bg-background" + ((storage.status.toLowerCase() === "success" || action === "delete") ? " hover:border-muted-foreground" : "")}
|
||||
${storage.status.toLowerCase() !== "success" && action !== "delete" ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`mt-0.5 h-4 w-4 shrink-0 rounded-full border ${
|
||||
field.value === storage.id ? "border-foreground" : "border-muted-foreground"
|
||||
} flex items-center justify-center`}
|
||||
>
|
||||
{field.value === storage.id &&
|
||||
<div className="h-2 w-2 rounded-full bg-foreground"/>}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="shrink-0">
|
||||
{getChannelIcon(storage.storageChannel?.provider || "")}
|
||||
</div>
|
||||
<h3 className="font-medium text-foreground truncate">
|
||||
{storage.storageChannel?.name}
|
||||
</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>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
:
|
||||
<Alert>
|
||||
<AlertCircleIcon/>
|
||||
<AlertTitle>Backup does not have files</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>You can safely delete the entire backup; no files seem to be related. Maybe an error
|
||||
occurred.</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
}
|
||||
|
||||
<div className="flex flex-row items-center gap-x-4 w-full">
|
||||
{action === "delete" && (
|
||||
<ButtonWithLoading
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => mutationDeleteEntireBackup.mutateAsync()}
|
||||
isPending={mutationDeleteEntireBackup.isPending}
|
||||
disabled={mutationDeleteEntireBackup.isPending}
|
||||
>
|
||||
Delete entire backup
|
||||
</ButtonWithLoading>
|
||||
)}
|
||||
|
||||
{filteredBackupStorages.length > 0 && (
|
||||
<ButtonWithLoading
|
||||
type="submit"
|
||||
isPending={mutation.isPending}
|
||||
disabled={mutation.isPending}
|
||||
className="ml-auto"
|
||||
>
|
||||
Confirm
|
||||
</ButtonWithLoading>
|
||||
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
{filteredBackupStorages.length > 0 && (
|
||||
<ButtonWithLoading
|
||||
type="submit"
|
||||
isPending={mutation.isPending}
|
||||
disabled={mutation.isPending}
|
||||
className="ml-auto"
|
||||
>
|
||||
Confirm
|
||||
</ButtonWithLoading>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
"use client";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { GearIcon } from "@radix-ui/react-icons";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export type EditButtonProps = {};
|
||||
|
||||
export const EditButton = (props: EditButtonProps) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<Link className={buttonVariants({ variant: "outline" })} href={`${pathname}/edit`}>
|
||||
<GearIcon className="w-7 h-7" />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -3,13 +3,18 @@
|
||||
import { ServerActionResult } from "@/types/action-type";
|
||||
import { z } from "zod";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import { auth, getPasskeys, revokePasskey } from "@/lib/auth/auth";
|
||||
import { userAction } from "@/lib/safe-actions/actions";
|
||||
|
||||
const RevokeSessionSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
const RevokePasskeySchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
|
||||
export const revokeSessionAction = userAction.schema(RevokeSessionSchema).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
@@ -99,3 +104,46 @@ export const revokeAllSessionsAction = userAction.action(async (): Promise<Serve
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export const getPasskeysAction = userAction.action(async (): Promise<ServerActionResult<any[]>> => {
|
||||
try {
|
||||
const passkeys = await getPasskeys();
|
||||
return {
|
||||
success: true,
|
||||
value: passkeys || [],
|
||||
actionSuccess: {
|
||||
message: "passkeys_fetched",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "error_fetching_passkeys",
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const revokePasskeyAction = userAction.schema(RevokePasskeySchema).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => {
|
||||
try {
|
||||
await revokePasskey(parsedInput.id);
|
||||
return {
|
||||
success: true,
|
||||
value: {},
|
||||
actionSuccess: {
|
||||
message: "passkey_revoked",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "error_revoking_passkey",
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1,184 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import React, {useState} from "react";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {Loader2, AlertTriangle} from "lucide-react";
|
||||
import {Account} from "@/db/schema/02_user";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
import {Alert, AlertDescription} from "@/components/ui/alert";
|
||||
import {SetPasswordProfileProviderModal} from "./modal/set-password-modal";
|
||||
import {AuthProviderConfig} from "../../../../../portabase.config";
|
||||
import {Icon} from "@iconify/react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Loader2, AlertTriangle } from "lucide-react";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { SetPasswordProfileProviderModal } from "./modal/set-password-modal";
|
||||
import { Icon } from "@iconify/react";
|
||||
import Image from "next/image";
|
||||
import type { AuthProviderConfig } from "@/lib/auth/config";
|
||||
import type { Account } from "@/db/schema/02_user";
|
||||
|
||||
interface ProfileProviderProps {
|
||||
accounts: Account[];
|
||||
providers: AuthProviderConfig[]
|
||||
|
||||
accounts: Account[];
|
||||
providers: AuthProviderConfig[];
|
||||
}
|
||||
|
||||
export function ProfileProviders({accounts, providers}: ProfileProviderProps) {
|
||||
const router = useRouter();
|
||||
const totalConnected = accounts.length;
|
||||
const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
|
||||
export function ProfileProviders({
|
||||
accounts,
|
||||
providers,
|
||||
}: ProfileProviderProps) {
|
||||
const router = useRouter();
|
||||
const totalConnected = accounts.length;
|
||||
const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
|
||||
|
||||
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
|
||||
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
|
||||
|
||||
const {mutate: unlinkAccount} = useMutation({
|
||||
mutationFn: async (providerId: string) => {
|
||||
setLoadingProvider(providerId);
|
||||
const {error} = await authClient.unlinkAccount({ providerId });
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Provider successfully unlinked!");
|
||||
setLoadingProvider(null);
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("An error occurred while unlinking provider.");
|
||||
setLoadingProvider(null);
|
||||
},
|
||||
});
|
||||
const { mutate: unlinkAccount } = useMutation({
|
||||
mutationFn: async (providerId: string) => {
|
||||
setLoadingProvider(providerId);
|
||||
const { error } = await authClient.unlinkAccount({ providerId });
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Provider successfully unlinked!");
|
||||
setLoadingProvider(null);
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("An error occurred while unlinking provider.");
|
||||
setLoadingProvider(null);
|
||||
},
|
||||
});
|
||||
|
||||
const {mutate: linkAccount} = useMutation({
|
||||
mutationFn: async (providerId: string) => {
|
||||
setLoadingProvider(providerId);
|
||||
const {error} = await authClient.signIn.social({
|
||||
provider: providerId as "google" | "github" | "credential",
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Provider successfully Linked!");
|
||||
setLoadingProvider(null);
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("An error occurred while linking provider.");
|
||||
setLoadingProvider(null);
|
||||
},
|
||||
});
|
||||
const { mutate: linkAccount } = useMutation({
|
||||
mutationFn: async (provider: AuthProviderConfig) => {
|
||||
setLoadingProvider(provider.id);
|
||||
if (provider.type === "social") {
|
||||
await authClient.linkSocial({
|
||||
provider: provider.id as string,
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
setLoadingProvider(null);
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("An error occurred while linking provider.");
|
||||
setLoadingProvider(null);
|
||||
},
|
||||
});
|
||||
|
||||
const enterpriseProviders = providers.filter(
|
||||
(p) => p.type === "sso" && p.id !== "passkey" && p.type !== "credential",
|
||||
);
|
||||
const otherProviders = providers.filter(
|
||||
(p) => p.type !== "sso" && p.id !== "passkey" && p.type !== "credential",
|
||||
);
|
||||
|
||||
const renderProvider = (provider: AuthProviderConfig) => {
|
||||
const linkedAccount = accounts.find(
|
||||
(acc) => acc.providerId === provider.id,
|
||||
);
|
||||
const isConnected = !!linkedAccount;
|
||||
const canUnlink =
|
||||
totalConnected > 1 || (totalConnected === 1 && !provider.isManual);
|
||||
const isLoading = loadingProvider === provider.id;
|
||||
|
||||
const isUnlinkDisabled =
|
||||
!canUnlink ||
|
||||
provider.allowUnlinking === false ||
|
||||
provider.type === "sso";
|
||||
|
||||
const unlinkButton = (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => unlinkAccount(provider.id)}
|
||||
disabled={isUnlinkDisabled || isLoading || provider.isManual}
|
||||
className={isUnlinkDisabled ? "opacity-50 cursor-not-allowed" : ""}
|
||||
>
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Unlink"}
|
||||
</Button>
|
||||
);
|
||||
|
||||
let actionElement;
|
||||
|
||||
if (provider.type === "sso") {
|
||||
actionElement = null;
|
||||
} else if (!isConnected) {
|
||||
actionElement =
|
||||
provider.id === "credential" ? (
|
||||
<SetPasswordProfileProviderModal
|
||||
open={isPasswordDialogOpen}
|
||||
onOpenChange={setIsPasswordDialogOpen}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => linkAccount(provider)}
|
||||
disabled={
|
||||
isLoading || provider.isManual || provider.allowLinking === false
|
||||
}
|
||||
>
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Link"}
|
||||
</Button>
|
||||
);
|
||||
} else if (isUnlinkDisabled) {
|
||||
actionElement = (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div tabIndex={0} className="inline-block">
|
||||
{unlinkButton}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{provider.allowUnlinking === false
|
||||
? "Unlinking is disabled for this provider."
|
||||
: "You cannot unlink your last authentication provider."}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
} else {
|
||||
actionElement = unlinkButton;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in-50 duration-300">
|
||||
<div className="mb-6 space-y-1">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Connected Accounts</h2>
|
||||
<p className="text-sm text-muted-foreground">Manage the providers used to sign in to your account.</p>
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
|
||||
{provider.icon.startsWith("/") ||
|
||||
provider.icon.startsWith("http") ? (
|
||||
<Image
|
||||
src={provider.icon}
|
||||
alt={provider.id}
|
||||
width={20}
|
||||
height={20}
|
||||
className="w-5 h-5"
|
||||
unoptimized={provider.icon.startsWith("http")}
|
||||
/>
|
||||
) : (
|
||||
<Icon icon={provider.icon} className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
{provider.title || provider.name}
|
||||
{isConnected && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0"
|
||||
>
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{providers.map((provider) => {
|
||||
const linkedAccount = accounts.find((acc) => acc.providerId === provider.id);
|
||||
const isConnected = !!linkedAccount;
|
||||
// const canUnlink = totalConnected > 1 || (totalConnected === 1 && !provider.isManual);
|
||||
const canUnlink = totalConnected > 1 ;
|
||||
// const isLoading = isUnlinking || isLinking;
|
||||
const isLoading = loadingProvider === provider.id;
|
||||
|
||||
return (
|
||||
<div key={provider.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
|
||||
<Icon icon={provider.icon} className="w-5 h-5"/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
{PROVIDERS_TEXT[provider.id].title}
|
||||
{isConnected && (
|
||||
<Badge variant="secondary"
|
||||
className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isConnected ? "Connected" : "Not Connected"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={0}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => unlinkAccount(provider.id)}
|
||||
disabled={!canUnlink || isLoading || provider.isManual}
|
||||
className={!canUnlink ? "opacity-50 cursor-not-allowed" : ""}
|
||||
>
|
||||
{isLoading ? <Loader2
|
||||
className="w-4 h-4 animate-spin"/> : "Unlink"}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!canUnlink && (
|
||||
<TooltipContent>
|
||||
<p>
|
||||
You cannot unlink your last authentication provider or if you
|
||||
don't have a password set.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<>
|
||||
{provider.id === "credential" ? (
|
||||
<SetPasswordProfileProviderModal open={isPasswordDialogOpen}
|
||||
onOpenChange={setIsPasswordDialogOpen}/>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => linkAccount(provider.id)}
|
||||
disabled={isLoading || provider.isManual}
|
||||
>
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin"/> : "Link"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{provider.description}
|
||||
</div>
|
||||
|
||||
<Alert variant={"default"}>
|
||||
<AlertTriangle className="w-5 h-5 shrink-0 mt-0.5"/>
|
||||
<AlertDescription>
|
||||
Linked providers allow you to log in to your account using any of these methods. If you use the same
|
||||
email address with another provider, it will be automatically linked when you log in.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">{actionElement}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in-50 duration-300 pb-10">
|
||||
<div className="mb-6 space-y-1">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Authentication
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage how you access your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{enterpriseProviders.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Enterprise Connection
|
||||
</h3>
|
||||
<div className="grid gap-4">
|
||||
{enterpriseProviders.map(renderProvider)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Standard Connections
|
||||
</h3>
|
||||
<div className="grid gap-4">{otherProviders.map(renderProvider)}</div>
|
||||
</div>
|
||||
|
||||
<Alert variant={"default"}>
|
||||
<AlertTriangle className="w-5 h-5 shrink-0 mt-0.5" />
|
||||
<AlertDescription>
|
||||
Linked providers allow you to log in to your account using any of
|
||||
these methods.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const PROVIDERS_TEXT = {
|
||||
credential: {
|
||||
title: "Password",
|
||||
description: "Use your email address and password to sign in.",
|
||||
},
|
||||
google: {
|
||||
title: "Google",
|
||||
description: "Sign in with your Google account.",
|
||||
},
|
||||
github: {
|
||||
title: "GitHub",
|
||||
description: "Sign in with your GitHub account.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,220 +1,493 @@
|
||||
"use client";
|
||||
|
||||
import {useState} from "react";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {Globe, LogOut, Loader2} from "lucide-react";
|
||||
import {Account, Session, User} from "@/db/schema/02_user";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
import {revokeAllSessionsAction, revokeSessionAction} from "./actions/security.action";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {ResetPasswordProfileProviderModal} from "./modal/reset-password-modal";
|
||||
import {SetPasswordProfileProviderModal} from "./modal/set-password-modal";
|
||||
import {Setup2FAProfileProviderModal} from "./modal/setup-2fa-modal";
|
||||
import {Disable2FAProfileProviderModal} from "./modal/disable-2fa-modal";
|
||||
import {ViewBackupCodesModal} from "./modal/view-backup-codes-modal";
|
||||
import {getDeviceDetails} from "@/utils/detection";
|
||||
import {timeAgo} from "@/utils/date-formatting";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Globe,
|
||||
LogOut,
|
||||
Loader2,
|
||||
Fingerprint,
|
||||
Trash2,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
revokeAllSessionsAction,
|
||||
revokeSessionAction,
|
||||
getPasskeysAction,
|
||||
revokePasskeyAction,
|
||||
} from "./actions/security.action";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ResetPasswordProfileProviderModal } from "./modal/reset-password-modal";
|
||||
import { SetPasswordProfileProviderModal } from "./modal/set-password-modal";
|
||||
import { Setup2FAProfileProviderModal } from "./modal/setup-2fa-modal";
|
||||
import { Disable2FAProfileProviderModal } from "./modal/disable-2fa-modal";
|
||||
import { ViewBackupCodesModal } from "./modal/view-backup-codes-modal";
|
||||
import { getDeviceDetails } from "@/utils/detection";
|
||||
import { timeAgo } from "@/utils/date-formatting";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Account, Session, User } from "@/db/schema/02_user";
|
||||
import { Icon } from "@iconify/react";
|
||||
import Image from "next/image";
|
||||
import type { AuthProviderConfig } from "@/lib/auth/config";
|
||||
import { is } from "date-fns/locale";
|
||||
|
||||
interface ProfileSecurityProps {
|
||||
user: User;
|
||||
sessions: Session[];
|
||||
credentialAccount: Account;
|
||||
currentSession: Session;
|
||||
user: User;
|
||||
sessions: Session[];
|
||||
credentialAccount: Account;
|
||||
currentSession: Session;
|
||||
isPasswordEnabled?: boolean;
|
||||
isPasskeyEnabled?: boolean;
|
||||
providers: AuthProviderConfig[];
|
||||
}
|
||||
|
||||
export function ProfileSecurity({user, sessions, credentialAccount, currentSession}: ProfileSecurityProps) {
|
||||
const router = useRouter();
|
||||
export function ProfileSecurity({
|
||||
user,
|
||||
sessions,
|
||||
credentialAccount,
|
||||
currentSession,
|
||||
isPasswordEnabled = false,
|
||||
isPasskeyEnabled = false,
|
||||
providers,
|
||||
}: ProfileSecurityProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false);
|
||||
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
|
||||
const [isSetup2FADialogOpen, setIsSetup2FADialogOpen] = useState(false);
|
||||
const [isDisable2FADialogOpen, setIsDisable2FADialogOpen] = useState(false);
|
||||
const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false);
|
||||
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
|
||||
const [isSetup2FADialogOpen, setIsSetup2FADialogOpen] = useState(false);
|
||||
const [isDisable2FADialogOpen, setIsDisable2FADialogOpen] = useState(false);
|
||||
const [isAddPasskeyOpen, setIsAddPasskeyOpen] = useState(false);
|
||||
const [passkeyName, setPasskeyName] = useState("");
|
||||
|
||||
const {mutate: revokeSession, isPending: isRevoking} = useMutation({
|
||||
mutationFn: async (token: string) => {
|
||||
const result = await revokeSessionAction({token});
|
||||
const inner = result?.data;
|
||||
if (inner?.success) {
|
||||
toast.success("Session successfully revoked");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("An error occurred while revoking session");
|
||||
}
|
||||
},
|
||||
});
|
||||
const { mutate: revokeSession, isPending: isRevoking } = useMutation({
|
||||
mutationFn: async (token: string) => {
|
||||
const result = await revokeSessionAction({ token });
|
||||
const inner = result?.data;
|
||||
if (inner?.success) {
|
||||
toast.success("Session successfully revoked");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("An error occurred while revoking session");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const {mutate: revokeOthers, isPending: isRevokingOthers} = useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await revokeAllSessionsAction();
|
||||
const inner = result?.data;
|
||||
if (inner?.success) {
|
||||
toast.success("Revoking all sessions successfully done.");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("An error occurred while revoking all sessions");
|
||||
}
|
||||
},
|
||||
});
|
||||
const { mutate: revokeOthers, isPending: isRevokingOthers } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await revokeAllSessionsAction();
|
||||
const inner = result?.data;
|
||||
if (inner?.success) {
|
||||
toast.success("Revoking all sessions successfully done.");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("An error occurred while revoking all sessions");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in-50 duration-300">
|
||||
<div className="mb-6 space-y-1">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Security Settings</h2>
|
||||
<p className="text-sm text-muted-foreground">Manage your password, two-factor authentication and
|
||||
sessions.</p>
|
||||
</div>
|
||||
const {
|
||||
data: passkeys,
|
||||
isLoading: isLoadingPasskeys,
|
||||
refetch: refetchPasskeys,
|
||||
} = useQuery({
|
||||
queryKey: ["passkeys"],
|
||||
queryFn: async () => {
|
||||
const result = await getPasskeysAction();
|
||||
if (result?.data?.success) {
|
||||
return result.data.value;
|
||||
}
|
||||
throw new Error("Failed to fetch passkeys");
|
||||
},
|
||||
});
|
||||
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium">Authentication</h3>
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">Password</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{user.lastChangedPasswordAt
|
||||
? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}`
|
||||
: "Never changed"}
|
||||
</div>
|
||||
</div>
|
||||
{credentialAccount ? (
|
||||
<ResetPasswordProfileProviderModal open={isPasswordDialogOpen}
|
||||
onOpenChange={setIsPasswordDialogOpen}/>
|
||||
) : (
|
||||
<SetPasswordProfileProviderModal open={isPasswordDialogOpen}
|
||||
onOpenChange={setIsPasswordDialogOpen}/>
|
||||
)}
|
||||
</div>
|
||||
const { mutate: revokePasskey, isPending: isRevokingPasskey } = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const result = await revokePasskeyAction({ id });
|
||||
if (!result?.data?.success) {
|
||||
throw new Error("Failed to revoke passkey");
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Passkey revoked successfully");
|
||||
refetchPasskeys();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to revoke passkey");
|
||||
},
|
||||
});
|
||||
|
||||
<Separator/>
|
||||
const { mutate: addPasskey, isPending: isAddingPasskey } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await authClient.passkey.addPasskey({
|
||||
name: passkeyName || "My Passkey",
|
||||
});
|
||||
if (result?.error) {
|
||||
throw result.error;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Passkey added successfully");
|
||||
setIsAddPasskeyOpen(false);
|
||||
setPasskeyName("");
|
||||
refetchPasskeys();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || "Failed to add passkey");
|
||||
},
|
||||
});
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium">Two-Factor Authentication</div>
|
||||
{user.twoFactorEnabled && (
|
||||
<Badge variant="secondary"
|
||||
className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Enhance the security of your account by
|
||||
requiring a second form of verification during login.
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in-50 duration-300">
|
||||
<div className="mb-6 space-y-1">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Security Settings
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage your password, two-factor authentication and sessions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{user.twoFactorEnabled ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ViewBackupCodesModal open={isBackupCodesDialogOpen}
|
||||
onOpenChange={setIsBackupCodesDialogOpen}/>
|
||||
<Disable2FAProfileProviderModal open={isDisable2FADialogOpen}
|
||||
onOpenChange={setIsDisable2FADialogOpen}/>
|
||||
</div>
|
||||
) : (
|
||||
<Setup2FAProfileProviderModal
|
||||
disabled={!credentialAccount}
|
||||
open={isSetup2FADialogOpen}
|
||||
onOpenChange={setIsSetup2FADialogOpen}
|
||||
/>
|
||||
)}
|
||||
{(isPasskeyEnabled || isPasswordEnabled || user.twoFactorEnabled) && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium">Authentication</h3>
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
{isPasswordEnabled && (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">Password</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{user.lastChangedPasswordAt
|
||||
? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}`
|
||||
: "Never changed"}
|
||||
</div>
|
||||
</div>
|
||||
{credentialAccount ? (
|
||||
<ResetPasswordProfileProviderModal
|
||||
open={isPasswordDialogOpen}
|
||||
onOpenChange={setIsPasswordDialogOpen}
|
||||
/>
|
||||
) : (
|
||||
<SetPasswordProfileProviderModal
|
||||
open={isPasswordDialogOpen}
|
||||
onOpenChange={setIsPasswordDialogOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium">Active Sessions</h3>
|
||||
{sessions && sessions.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => revokeOthers()}
|
||||
disabled={isRevokingOthers || (sessions?.length || 0) <= 1}
|
||||
>
|
||||
{isRevokingOthers && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
|
||||
Revoke All
|
||||
</Button>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{isPasswordEnabled && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium">Two-Factor Authentication</div>
|
||||
{user.twoFactorEnabled && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0"
|
||||
>
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Enhance the security of your account by requiring a second
|
||||
form of verification during login.
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg divide-y">
|
||||
{sessions && sessions.length > 0 ? (
|
||||
sessions?.map((session) => (
|
||||
<SessionRow
|
||||
key={session.id}
|
||||
session={session}
|
||||
onRevoke={(token) => revokeSession(token)}
|
||||
isRevoking={isRevoking}
|
||||
currentSession={currentSession}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 text-center text-muted-foreground">No active sessions found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.twoFactorEnabled ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ViewBackupCodesModal
|
||||
open={isBackupCodesDialogOpen}
|
||||
onOpenChange={setIsBackupCodesDialogOpen}
|
||||
/>
|
||||
<Disable2FAProfileProviderModal
|
||||
open={isDisable2FADialogOpen}
|
||||
onOpenChange={setIsDisable2FADialogOpen}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Setup2FAProfileProviderModal
|
||||
disabled={!credentialAccount}
|
||||
open={isSetup2FADialogOpen}
|
||||
onOpenChange={setIsSetup2FADialogOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)}
|
||||
|
||||
{isPasskeyEnabled && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-medium">Passkeys</h3>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Login securely with your fingerprint, face recognition, or
|
||||
hardware key.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isAddPasskeyOpen} onOpenChange={setIsAddPasskeyOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Passkey
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Passkey</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a name for your passkey to identify it later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Passkey Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="e.g. MacBook Pro, iPhone, YubiKey"
|
||||
value={passkeyName}
|
||||
onChange={(e) => setPasskeyName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsAddPasskeyOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => addPasskey()}
|
||||
disabled={isAddingPasskey}
|
||||
>
|
||||
{isAddingPasskey && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Create Passkey
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg divide-y">
|
||||
{isLoadingPasskeys ? (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : passkeys && passkeys.length > 0 ? (
|
||||
passkeys.map((pk: any) => (
|
||||
<PasskeyRow
|
||||
key={pk.id}
|
||||
passkey={pk}
|
||||
onRevoke={(id) => revokePasskey(id)}
|
||||
isRevoking={isRevokingPasskey}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
No passkeys found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6 pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium">Active Sessions</h3>
|
||||
{sessions && sessions.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => revokeOthers()}
|
||||
disabled={isRevokingOthers || (sessions?.length || 0) <= 1}
|
||||
>
|
||||
{isRevokingOthers && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Revoke All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="border rounded-lg divide-y">
|
||||
{sessions && sessions.length > 0 ? (
|
||||
sessions?.map((session) => (
|
||||
<SessionRow
|
||||
key={session.id}
|
||||
session={session}
|
||||
onRevoke={(token) => revokeSession(token)}
|
||||
isRevoking={isRevoking}
|
||||
currentSession={currentSession}
|
||||
providers={providers}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
No active sessions found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionRow({
|
||||
session,
|
||||
onRevoke,
|
||||
isRevoking,
|
||||
currentSession,
|
||||
}: {
|
||||
session: Session;
|
||||
onRevoke: (token: string) => void;
|
||||
isRevoking: boolean;
|
||||
currentSession: Session;
|
||||
session,
|
||||
onRevoke,
|
||||
isRevoking,
|
||||
currentSession,
|
||||
providers,
|
||||
}: {
|
||||
session: Session;
|
||||
onRevoke: (token: string) => void;
|
||||
isRevoking: boolean;
|
||||
currentSession: Session;
|
||||
providers: AuthProviderConfig[];
|
||||
}) {
|
||||
const deviceInfo = getDeviceDetails(session.userAgent);
|
||||
const deviceInfo = getDeviceDetails(session.userAgent);
|
||||
const provider = providers.find((p) => p.id === (session as any).providerId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
|
||||
<deviceInfo.Icon className="w-5 h-5"/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium flex items-center gap-2">
|
||||
{deviceInfo.os} <span
|
||||
className="text-muted-foreground font-normal">• {deviceInfo.browser}</span>
|
||||
{session.id === currentSession.id && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] h-5 px-1.5 text-sky-600 bg-sky-50 border-sky-200 dark:bg-sky-900/20 dark:border-sky-800 dark:text-sky-400"
|
||||
>
|
||||
This device
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Globe className="w-3 h-3"/> {session.ipAddress} •
|
||||
<span className="ml-1">
|
||||
{session.id === currentSession.id
|
||||
? "Active now"
|
||||
: `Last active ${timeAgo(new Date(session.createdAt))}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground relative">
|
||||
<deviceInfo.Icon className="w-5 h-5" />
|
||||
{provider && (
|
||||
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-background border flex items-center justify-center overflow-hidden">
|
||||
{provider.icon.startsWith("/") ||
|
||||
provider.icon.startsWith("http") ? (
|
||||
<Image
|
||||
src={provider.icon}
|
||||
alt={provider.id}
|
||||
width={12}
|
||||
height={12}
|
||||
className="w-3 h-3"
|
||||
unoptimized={provider.icon.startsWith("http")}
|
||||
/>
|
||||
) : (
|
||||
<Icon icon={provider.icon} className="w-3 h-3" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{session.id !== currentSession.id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onRevoke(session.token)}
|
||||
disabled={isRevoking}
|
||||
>
|
||||
{isRevoking ? <Loader2 className="h-4 w-4 animate-spin"/> : <LogOut className="w-4 h-4"/>}
|
||||
<span className="sr-only">Revoke</span>
|
||||
</Button>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium flex items-center gap-2">
|
||||
{deviceInfo.os}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
• {deviceInfo.browser}
|
||||
</span>
|
||||
{provider && (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
• {provider.title || provider.name}
|
||||
</span>
|
||||
)}
|
||||
{session.id === currentSession.id && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] h-5 px-1.5 text-sky-600 bg-sky-50 border-sky-200 dark:bg-sky-900/20 dark:border-sky-800 dark:text-sky-400"
|
||||
>
|
||||
This device
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Globe className="w-3 h-3" /> {session.ipAddress} •
|
||||
<span className="ml-1">
|
||||
{session.id === currentSession.id
|
||||
? "Active now"
|
||||
: `Last active ${timeAgo(new Date(session.createdAt))}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{session.id !== currentSession.id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onRevoke(session.token)}
|
||||
disabled={isRevoking}
|
||||
>
|
||||
{isRevoking ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LogOut className="w-4 h-4" />
|
||||
)}
|
||||
<span className="sr-only">Revoke</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasskeyRow({
|
||||
passkey,
|
||||
onRevoke,
|
||||
isRevoking,
|
||||
}: {
|
||||
passkey: any;
|
||||
onRevoke: (id: string) => void;
|
||||
isRevoking: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
|
||||
<Fingerprint className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium text-sm">
|
||||
{passkey.name || "Unnamed Passkey"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Created {timeAgo(new Date(passkey.createdAt))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onRevoke(passkey.id)}
|
||||
disabled={isRevoking}
|
||||
>
|
||||
{isRevoking ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
<span className="sr-only">Revoke</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,144 +1,158 @@
|
||||
"use client"
|
||||
import {DatabaseBackupActionsModal} from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-modal";
|
||||
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database";
|
||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||
import {useBackupModal} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
||||
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
|
||||
import {useQuery, useQueryClient} from "@tanstack/react-query";
|
||||
import {getDatabaseDataAction} from "@/components/wrappers/dashboard/database/backup/actions/get-data.action";
|
||||
import {PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
|
||||
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
|
||||
import {ChannelPoliciesModal} from "@/components/wrappers/dashboard/database/channels-policy/policy-modal";
|
||||
import {HardDrive, Megaphone} from "lucide-react";
|
||||
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
|
||||
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
||||
|
||||
"use client";
|
||||
import { DatabaseBackupActionsModal } from "@/components/wrappers/dashboard/database/backup/actions/backup-actions-modal";
|
||||
import { DatabaseTabs } from "@/components/wrappers/dashboard/projects/database/database-tabs";
|
||||
import { Setting } from "@/db/schema/01_setting";
|
||||
import { BackupWith, DatabaseWith, Restoration } from "@/db/schema/07_database";
|
||||
import { MemberWithUser } from "@/db/schema/03_organization";
|
||||
import { useBackupModal } from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
||||
import { DatabaseKpi } from "@/components/wrappers/dashboard/projects/database/database-kpi";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getDatabaseDataAction } from "@/components/wrappers/dashboard/database/backup/actions/get-data.action";
|
||||
import {
|
||||
PageContent,
|
||||
PageDescription,
|
||||
PageTitle,
|
||||
} from "@/features/layout/page";
|
||||
import { capitalizeFirstLetter } from "@/utils/text";
|
||||
import { RetentionPolicySheet } from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
|
||||
import { CronButton } from "@/components/wrappers/dashboard/database/cron-button/cron-button";
|
||||
import { ChannelPoliciesModal } from "@/components/wrappers/dashboard/database/channels-policy/policy-modal";
|
||||
import { HardDrive, Megaphone } from "lucide-react";
|
||||
import { ImportModal } from "@/components/wrappers/dashboard/database/import/import-modal";
|
||||
import { BackupButton } from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
||||
|
||||
export type DatabaseContentProps = {
|
||||
settings: Setting,
|
||||
backups: BackupWith[],
|
||||
restorations: Restoration[],
|
||||
isAlreadyRestore: boolean,
|
||||
database: DatabaseWith,
|
||||
activeMember: MemberWithUser,
|
||||
totalBackups: number,
|
||||
availableBackups: number,
|
||||
successRate: number | null,
|
||||
organizationId: string,
|
||||
activeOrganizationChannels: any[],
|
||||
activeOrganizationStorageChannels: any[]
|
||||
}
|
||||
|
||||
settings: Setting;
|
||||
backups: BackupWith[];
|
||||
restorations: Restoration[];
|
||||
isAlreadyRestore: boolean;
|
||||
database: DatabaseWith;
|
||||
activeMember: MemberWithUser;
|
||||
totalBackups: number;
|
||||
availableBackups: number;
|
||||
successRate: number | null;
|
||||
organizationId: string;
|
||||
activeOrganizationChannels: any[];
|
||||
activeOrganizationStorageChannels: any[];
|
||||
};
|
||||
|
||||
export const DatabaseContent = (props: DatabaseContentProps) => {
|
||||
const {} = useBackupModal();
|
||||
const {} = useBackupModal();
|
||||
|
||||
|
||||
const {data} = useQuery({
|
||||
queryKey: ["database-data", props.database.id],
|
||||
queryFn: async () => {
|
||||
const result = await getDatabaseDataAction({databaseId: props.database.id});
|
||||
return result?.data;
|
||||
},
|
||||
initialData: {
|
||||
// TODO : to be patched
|
||||
// @ts-ignore
|
||||
database: {
|
||||
...props.database,
|
||||
project: props.database.project ?? null,
|
||||
},
|
||||
backups: props.backups,
|
||||
restorations: props.restorations,
|
||||
activeOrganizationChannels: props.activeOrganizationChannels,
|
||||
activeOrganizationStorageChannels: props.activeOrganizationStorageChannels,
|
||||
stats: {
|
||||
totalBackups: props.totalBackups,
|
||||
availableBackups: props.availableBackups,
|
||||
successRate: props.successRate
|
||||
}
|
||||
},
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchInterval: 1000,
|
||||
});
|
||||
|
||||
const database = data?.database ?? props.database;
|
||||
const backups = data?.backups ?? props.backups;
|
||||
const restorations = data?.restorations ?? props.restorations;
|
||||
const activeOrganizationChannels = data?.activeOrganizationChannels ?? props.activeOrganizationChannels;
|
||||
const activeOrganizationStorageChannels = data?.activeOrganizationStorageChannels ?? props.activeOrganizationStorageChannels;
|
||||
const stats = data?.stats ?? {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["database-data", props.database.id],
|
||||
queryFn: async () => {
|
||||
const result = await getDatabaseDataAction({
|
||||
databaseId: props.database.id,
|
||||
});
|
||||
return result?.data;
|
||||
},
|
||||
initialData: {
|
||||
// TODO : to be patched
|
||||
// @ts-ignore
|
||||
database: {
|
||||
...props.database,
|
||||
project: props.database.project ?? null,
|
||||
},
|
||||
backups: props.backups,
|
||||
restorations: props.restorations,
|
||||
activeOrganizationChannels: props.activeOrganizationChannels,
|
||||
activeOrganizationStorageChannels:
|
||||
props.activeOrganizationStorageChannels,
|
||||
stats: {
|
||||
totalBackups: props.totalBackups,
|
||||
availableBackups: props.availableBackups,
|
||||
successRate: props.successRate
|
||||
};
|
||||
successRate: props.successRate,
|
||||
},
|
||||
},
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchInterval: 1000,
|
||||
});
|
||||
|
||||
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
|
||||
const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
|
||||
const database = data?.database ?? props.database;
|
||||
const backups = data?.backups ?? props.backups;
|
||||
const restorations = data?.restorations ?? props.restorations;
|
||||
const activeOrganizationChannels =
|
||||
data?.activeOrganizationChannels ?? props.activeOrganizationChannels;
|
||||
const activeOrganizationStorageChannels =
|
||||
data?.activeOrganizationStorageChannels ??
|
||||
props.activeOrganizationStorageChannels;
|
||||
const stats = data?.stats ?? {
|
||||
totalBackups: props.totalBackups,
|
||||
availableBackups: props.availableBackups,
|
||||
successRate: props.successRate,
|
||||
};
|
||||
|
||||
const isMember = props.activeMember.role === "member";
|
||||
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
|
||||
const isAlreadyBackup = backups.some(
|
||||
(b) => b.status === "waiting" || b.status === "ongoing",
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(database.name)}
|
||||
</div>
|
||||
{!isMember && (
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<RetentionPolicySheet database={database}/>
|
||||
<CronButton database={database}/>
|
||||
<ChannelPoliciesModal
|
||||
database={database}
|
||||
kind={"notification"}
|
||||
icon={<Megaphone/>}
|
||||
channels={activeOrganizationChannels}
|
||||
organizationId={props.organizationId}
|
||||
/>
|
||||
<ChannelPoliciesModal
|
||||
database={database}
|
||||
icon={<HardDrive/>}
|
||||
kind={"storage"}
|
||||
channels={activeOrganizationStorageChannels}
|
||||
organizationId={props.organizationId}
|
||||
/>
|
||||
<ImportModal database={database}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BackupButton disable={isAlreadyBackup} databaseId={database.id}/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageTitle>
|
||||
const isMember = props.activeMember.role === "member";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(database.name)}
|
||||
</div>
|
||||
{!isMember && (
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<RetentionPolicySheet database={database} />
|
||||
<CronButton database={database} />
|
||||
<ChannelPoliciesModal
|
||||
database={database}
|
||||
kind={"notification"}
|
||||
icon={<Megaphone />}
|
||||
channels={activeOrganizationChannels}
|
||||
organizationId={props.organizationId}
|
||||
/>
|
||||
<ChannelPoliciesModal
|
||||
database={database}
|
||||
icon={<HardDrive />}
|
||||
kind={"storage"}
|
||||
channels={activeOrganizationStorageChannels}
|
||||
organizationId={props.organizationId}
|
||||
/>
|
||||
<ImportModal database={database} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BackupButton
|
||||
disable={isAlreadyBackup}
|
||||
databaseId={database.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageTitle>
|
||||
</div>
|
||||
|
||||
{database.description && (
|
||||
<PageDescription className="mt-5 sm:mt-0">{database.description}</PageDescription>
|
||||
)}
|
||||
{database.description && (
|
||||
<PageDescription className="mt-5 sm:mt-0">
|
||||
{database.description}
|
||||
</PageDescription>
|
||||
)}
|
||||
|
||||
<PageContent className="flex flex-col w-full h-full">
|
||||
<DatabaseKpi
|
||||
successRate={stats.successRate}
|
||||
database={database}
|
||||
availableBackups={stats.availableBackups}
|
||||
totalBackups={stats.totalBackups}
|
||||
/>
|
||||
<DatabaseBackupActionsModal/>
|
||||
<DatabaseTabs
|
||||
activeMember={props.activeMember}
|
||||
settings={props.settings}
|
||||
database={database}
|
||||
isAlreadyRestore={isAlreadyRestore}
|
||||
backups={backups}
|
||||
restorations={restorations}
|
||||
/>
|
||||
</PageContent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<PageContent className="flex flex-col w-full h-full">
|
||||
<DatabaseKpi
|
||||
successRate={stats.successRate}
|
||||
database={database}
|
||||
availableBackups={stats.availableBackups}
|
||||
totalBackups={stats.totalBackups}
|
||||
/>
|
||||
<DatabaseBackupActionsModal />
|
||||
<DatabaseTabs
|
||||
activeMember={props.activeMember}
|
||||
settings={props.settings}
|
||||
database={database}
|
||||
isAlreadyRestore={isAlreadyRestore}
|
||||
backups={backups}
|
||||
restorations={restorations}
|
||||
/>
|
||||
</PageContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."dbms_status" ADD VALUE 'sqlite';
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE "sso_provider" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"issuer" text NOT NULL,
|
||||
"oidc_config" json,
|
||||
"saml_config" json,
|
||||
"user_id" uuid,
|
||||
"provider_id" text NOT NULL,
|
||||
"organization_id" text,
|
||||
"domain" text NOT NULL,
|
||||
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "passkey" DROP CONSTRAINT "passkey_userId_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "passkey" ADD COLUMN "public_key" text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "passkey" ADD COLUMN "user_id" uuid NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "passkey" ADD COLUMN "credential_id" text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "passkey" ADD COLUMN "device_type" text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "passkey" ADD COLUMN "backed_up" boolean NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "passkey" ADD COLUMN "aaguid" text;--> statement-breakpoint
|
||||
ALTER TABLE "session" ADD COLUMN "provider_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "passkey" ADD CONSTRAINT "passkey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "passkey" DROP COLUMN "publicKey";--> statement-breakpoint
|
||||
ALTER TABLE "passkey" DROP COLUMN "userId";--> statement-breakpoint
|
||||
ALTER TABLE "passkey" DROP COLUMN "credentialId";--> statement-breakpoint
|
||||
ALTER TABLE "passkey" DROP COLUMN "deviceType";--> statement-breakpoint
|
||||
ALTER TABLE "passkey" DROP COLUMN "backedUp";
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "passkey" RENAME COLUMN "public_key" TO "publicKey";--> statement-breakpoint
|
||||
ALTER TABLE "passkey" RENAME COLUMN "credential_id" TO "credentialId";--> statement-breakpoint
|
||||
ALTER TABLE "passkey" RENAME COLUMN "device_type" TO "deviceType";--> statement-breakpoint
|
||||
ALTER TABLE "passkey" RENAME COLUMN "backed_up" TO "backedUp";
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "passkey" RENAME COLUMN "credentialId" TO "credentialID";
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -239,6 +239,34 @@
|
||||
"when": 1770923665015,
|
||||
"tag": "0033_handy_valeria_richards",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 34,
|
||||
"version": "7",
|
||||
"when": 1771790675240,
|
||||
"tag": "0034_lush_speed",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 35,
|
||||
"version": "7",
|
||||
"when": 1771922367910,
|
||||
"tag": "0035_late_young_avengers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 36,
|
||||
"version": "7",
|
||||
"when": 1772200673674,
|
||||
"tag": "0036_chief_night_thrasher",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 37,
|
||||
"version": "7",
|
||||
"when": 1772200831628,
|
||||
"tag": "0037_neat_talon",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+135
-104
@@ -1,147 +1,178 @@
|
||||
import {relations} from "drizzle-orm";
|
||||
import {boolean, integer, pgEnum, pgTable, text, timestamp, uuid} from "drizzle-orm/pg-core";
|
||||
import {createSelectSchema} from "drizzle-zod";
|
||||
import {z} from "zod";
|
||||
import {project} from "./06_project";
|
||||
import {member} from "@/db/schema/04_member";
|
||||
import {invitation} from "@/db/schema/05_invitation";
|
||||
import {organization} from "@/db/schema/03_organization";
|
||||
import {Account as BetterAuthAccount} from "better-auth";
|
||||
import {timestamps} from "@/db/schema/00_common";
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
boolean,
|
||||
integer,
|
||||
pgEnum,
|
||||
json,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { z } from "zod";
|
||||
import { project } from "./06_project";
|
||||
import { member } from "@/db/schema/04_member";
|
||||
import { invitation } from "@/db/schema/05_invitation";
|
||||
import { organization } from "@/db/schema/03_organization";
|
||||
import { Account as BetterAuthAccount } from "better-auth";
|
||||
import { timestamps } from "@/db/schema/00_common";
|
||||
|
||||
export const userThemeEnum = pgEnum("user_themes", ["light", "dark", "system"]);
|
||||
|
||||
|
||||
export const user = pgTable("user", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").notNull(),
|
||||
image: text("image"),
|
||||
role: text("role"),
|
||||
theme: userThemeEnum().notNull().default("light"),
|
||||
banned: boolean("banned"),
|
||||
banReason: text("ban_reason"),
|
||||
banExpires: timestamp("ban_expires"),
|
||||
lastConnectedAt: timestamp(),
|
||||
lastChangedPasswordAt: timestamp(),
|
||||
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
||||
...timestamps
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").notNull(),
|
||||
image: text("image"),
|
||||
role: text("role"),
|
||||
theme: userThemeEnum().notNull().default("light"),
|
||||
banned: boolean("banned"),
|
||||
banReason: text("ban_reason"),
|
||||
banExpires: timestamp("ban_expires"),
|
||||
lastConnectedAt: timestamp(),
|
||||
lastChangedPasswordAt: timestamp(),
|
||||
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const session = pgTable("session", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, {onDelete: "cascade"}),
|
||||
impersonatedBy: text("impersonated_by"), //id or name ????
|
||||
activeOrganizationId: text("active_organization_id"),
|
||||
...timestamps
|
||||
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
providerId: text("provider_id"),
|
||||
impersonatedBy: text("impersonated_by"), //id or name ????
|
||||
activeOrganizationId: text("active_organization_id"),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const account = pgTable("account", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, {onDelete: "cascade"}),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
...timestamps
|
||||
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const verification = pgTable("verification", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
...timestamps
|
||||
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const passkey = pgTable("passkey", {
|
||||
id: uuid().defaultRandom().primaryKey(),
|
||||
name: text(),
|
||||
publicKey: text().notNull(),
|
||||
userId: uuid()
|
||||
.notNull()
|
||||
.references(() => user.id, {onDelete: "cascade"}),
|
||||
credentialId: text().notNull(),
|
||||
counter: integer().notNull(),
|
||||
deviceType: text().notNull(),
|
||||
backedUp: boolean().notNull(),
|
||||
transports: text(),
|
||||
|
||||
...timestamps,
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
name: text("name"),
|
||||
publicKey: text().notNull(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
credentialID: text().notNull(),
|
||||
counter: integer().notNull(),
|
||||
deviceType: text().notNull(),
|
||||
backedUp: boolean().notNull(),
|
||||
transports: text("transports"),
|
||||
aaguid: text("aaguid"),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const twoFactor = pgTable("two_factor", {
|
||||
id: uuid().defaultRandom().primaryKey(),
|
||||
secret: text("secret").notNull(),
|
||||
backupCodes: text("backup_codes").notNull(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, {onDelete: "cascade"}),
|
||||
id: uuid().defaultRandom().primaryKey(),
|
||||
secret: text("secret").notNull(),
|
||||
backupCodes: text("backup_codes").notNull(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const userRelations = relations(user, ({many}) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
memberships: many(member),
|
||||
invitations: many(invitation),
|
||||
export const ssoProvider = pgTable("sso_provider", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
issuer: text("issuer").notNull(),
|
||||
oidcConfig: json("oidc_config"),
|
||||
samlConfig: json("saml_config"),
|
||||
userId: uuid("user_id").references(() => user.id, { onDelete: "cascade" }),
|
||||
providerId: text("provider_id").notNull().unique(),
|
||||
organizationId: text("organization_id"),
|
||||
domain: text("domain").notNull(),
|
||||
});
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
ssoProviders: many(ssoProvider),
|
||||
memberships: many(member),
|
||||
invitations: many(invitation),
|
||||
passkeys: many(passkey),
|
||||
}));
|
||||
|
||||
export const sessionRelations = relations(session, ({one}) => ({
|
||||
user: one(user, {
|
||||
fields: [session.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
export const sessionRelations = relations(session, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [session.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const accountRelations = relations(account, ({one}) => ({
|
||||
user: one(user, {
|
||||
fields: [account.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
export const accountRelations = relations(account, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [account.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const projectRelations = relations(project, ({one}) => ({
|
||||
organization: one(organization, {
|
||||
fields: [project.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [ssoProvider.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const projectRelations = relations(project, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [project.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const passkeyRelations = relations(passkey, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [passkey.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const userSchema = createSelectSchema(user);
|
||||
export type User = z.infer<typeof userSchema>;
|
||||
|
||||
export const userThemeEnumSchema = createSelectSchema(userThemeEnum)
|
||||
export const userThemeEnumSchema = createSelectSchema(userThemeEnum);
|
||||
export type UserThemeEnum = z.infer<typeof userThemeEnumSchema>;
|
||||
|
||||
|
||||
export const sessionSchema = createSelectSchema(session);
|
||||
export type Session = z.infer<typeof sessionSchema>;
|
||||
|
||||
export const accountSchema = createSelectSchema(account);
|
||||
export type Account = z.infer<typeof accountSchema>;
|
||||
|
||||
|
||||
type FixedAccount = Omit<BetterAuthAccount, 'updatedAt'> & {
|
||||
updatedAt: Date | null;
|
||||
type FixedAccount = Omit<BetterAuthAccount, "updatedAt"> & {
|
||||
updatedAt: Date | null;
|
||||
};
|
||||
|
||||
export type UserWithAccounts = User & {
|
||||
accounts: FixedAccount[];
|
||||
};
|
||||
accounts: FixedAccount[];
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ import {timestamps} from "@/db/schema/00_common";
|
||||
import {AlertPolicy, alertPolicy} from "@/db/schema/10_alert-policy";
|
||||
import {StoragePolicy, storagePolicy} from "@/db/schema/13_storage-policy";
|
||||
import {BackupStorage, backupStorage} from "@/db/schema/14_storage-backup";
|
||||
import {storageChannel} from "@/db/schema/12_storage-channel";
|
||||
|
||||
export const database = pgTable("databases", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
|
||||
@@ -2,7 +2,7 @@ import {pgEnum} from "drizzle-orm/pg-core";
|
||||
import {createSelectSchema} from "drizzle-zod";
|
||||
import {z} from "zod";
|
||||
|
||||
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "mongodb"]);
|
||||
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "mongodb", "sqlite"]);
|
||||
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
|
||||
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
|
||||
|
||||
|
||||
+11
-9
@@ -7,7 +7,6 @@ import {User, UserThemeEnum} from "@/db/schema/02_user";
|
||||
|
||||
export async function createUserDb(data: SignUpUser): Promise<User> {
|
||||
const now = new Date();
|
||||
const hashedPassword = await hashPassword(data.password);
|
||||
const userId = crypto.randomUUID();
|
||||
|
||||
const [newUser] = await db.insert(drizzleDb.schemas.user).values({
|
||||
@@ -21,14 +20,17 @@ export async function createUserDb(data: SignUpUser): Promise<User> {
|
||||
theme: data.theme as UserThemeEnum,
|
||||
}).returning();
|
||||
|
||||
await db.insert(drizzleDb.schemas.account).values({
|
||||
providerId: "credential",
|
||||
accountId: userId,
|
||||
userId: userId,
|
||||
password: hashedPassword,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
if (data.password) {
|
||||
const hashedPassword = await hashPassword(data.password);
|
||||
await db.insert(drizzleDb.schemas.account).values({
|
||||
providerId: "credential",
|
||||
accountId: userId,
|
||||
userId: userId,
|
||||
password: hashedPassword,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
return newUser
|
||||
}
|
||||
|
||||
+120
-50
@@ -1,71 +1,141 @@
|
||||
import {createEnv} from "@t3-oss/env-nextjs";
|
||||
import {z} from "zod";
|
||||
import packageJson from "../package.json" with {type: "json"};
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import path from "path";
|
||||
import { z } from "zod";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
|
||||
const {version} = packageJson;
|
||||
const { version } = packageJson;
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
|
||||
server: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
|
||||
|
||||
NODE_ENV: z.enum(["development", "production"]).optional(),
|
||||
NODE_ENV: z.enum(["development", "production"]).optional(),
|
||||
|
||||
DATABASE_URL: z.string().url().optional(),
|
||||
DATABASE_URL: z.string().url().optional(),
|
||||
|
||||
PROJECT_NAME: z.string().optional(),
|
||||
PROJECT_DESCRIPTION: z.string().optional(),
|
||||
PROJECT_URL: z.string().regex(/^https?:\/\//, "URL must start with http:// or https://"),
|
||||
PROJECT_SECRET: z.string(),
|
||||
PROJECT_NAME: z.string().optional(),
|
||||
PROJECT_DESCRIPTION: z.string().optional(),
|
||||
PROJECT_URL: z
|
||||
.string()
|
||||
.regex(/^https?:\/\//, "URL must start with http:// or https://"),
|
||||
PROJECT_SECRET: z.string(),
|
||||
|
||||
SMTP_PASSWORD: z.string().optional(),
|
||||
SMTP_FROM: z.string().optional(),
|
||||
SMTP_HOST: z.string().optional(),
|
||||
SMTP_PORT: z.string().optional(),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_SECURE: z.coerce.boolean().default(true),
|
||||
SMTP_PASSWORD: z.string().optional(),
|
||||
SMTP_FROM: z.string().optional(),
|
||||
SMTP_HOST: z.string().optional(),
|
||||
SMTP_PORT: z.string().optional(),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_SECURE: z.coerce.boolean().default(true),
|
||||
|
||||
AUTH_GOOGLE_ID: z.string().optional(),
|
||||
AUTH_GOOGLE_SECRET: z.string().optional(),
|
||||
AUTH_GOOGLE_METHOD: z.boolean().default(false),
|
||||
AUTH_GOOGLE_ID: z.string().optional(),
|
||||
AUTH_GOOGLE_SECRET: z.string().optional(),
|
||||
|
||||
STORAGE_TYPE: z.enum(["local", "s3"]).optional(),
|
||||
AUTH_GITHUB_ID: z.string().optional(),
|
||||
AUTH_GITHUB_SECRET: z.string().optional(),
|
||||
|
||||
RETENTION_CRON: z
|
||||
.string()
|
||||
.default(process.env.NODE_ENV === "production" ? "0 7 * * *" : "* * * * *"),
|
||||
RETENTION_CRON: z
|
||||
.string()
|
||||
.default(
|
||||
process.env.NODE_ENV === "production" ? "0 7 * * *" : "* * * * *",
|
||||
),
|
||||
|
||||
PRIVATE_PATH: z.string(),
|
||||
AUTH_OIDC_ID: z.string().optional().default("oidc"),
|
||||
AUTH_OIDC_TITLE: z.string().optional(),
|
||||
AUTH_OIDC_DESC: z.string().optional(),
|
||||
AUTH_OIDC_ICON: z.string().optional(),
|
||||
AUTH_OIDC_CLIENT: z.string().optional(),
|
||||
AUTH_OIDC_SECRET: z.string().optional(),
|
||||
AUTH_OIDC_ISSUER_URL: z.string().optional(),
|
||||
AUTH_OIDC_HOST: z.string().optional(),
|
||||
AUTH_OIDC_SCOPES: z.string().optional(),
|
||||
AUTH_OIDC_DISCOVERY_ENDPOINT: z.string().optional(),
|
||||
AUTH_OIDC_JWKS_ENDPOINT: z.string().optional(),
|
||||
AUTH_OIDC_PKCE: z.string().optional(),
|
||||
|
||||
},
|
||||
client: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
|
||||
},
|
||||
runtimeEnv: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: version || "Unknown Version",
|
||||
AUTH_SOCIAL_ID: z.string().optional().default("social"),
|
||||
AUTH_SOCIAL_TITLE: z.string().optional(),
|
||||
AUTH_SOCIAL_DESC: z.string().optional(),
|
||||
AUTH_SOCIAL_ICON: z.string().optional(),
|
||||
AUTH_SOCIAL_CLIENT: z.string().optional(),
|
||||
AUTH_SOCIAL_SECRET: z.string().optional(),
|
||||
AUTH_SOCIAL_APPLE_APP_BUNDLE_IDENTIFIER: z.string().optional(),
|
||||
|
||||
PROJECT_NAME: process.env.PROJECT_NAME,
|
||||
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
|
||||
PROJECT_URL: process.env.PROJECT_URL,
|
||||
PROJECT_SECRET: process.env.PROJECT_SECRET,
|
||||
ALLOWED_GROUP: z.string().optional(),
|
||||
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
AUTH_EMAIL_PASSWORD_ENABLED: z.string().optional().default("true"),
|
||||
AUTH_SIGNUP_ENABLED: z.string().optional().default("true"),
|
||||
AUTH_PASSKEY_ENABLED: z.string().optional().default("false"),
|
||||
|
||||
SMTP_PASSWORD: process.env.SMTP_PASSWORD,
|
||||
SMTP_FROM: process.env.SMTP_FROM,
|
||||
SMTP_HOST: process.env.SMTP_HOST,
|
||||
SMTP_PORT: process.env.SMTP_PORT,
|
||||
SMTP_USER: process.env.SMTP_USER,
|
||||
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||
AUTH_SYNC_OIDC_ROLES_ON_LOGIN: z.enum(["true", "false"]).default("true"),
|
||||
AUTH_ROLE_MAP: z.string().optional(),
|
||||
AUTH_DEFAULT_ROLE: z.string().optional().default("pending"),
|
||||
AUTH_ALLOW_LINKING: z.enum(["true", "false"]).default("true"),
|
||||
AUTH_ALLOW_UNLINKING: z.enum(["true", "false"]).default("true"),
|
||||
|
||||
AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
|
||||
AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
|
||||
AUTH_GOOGLE_METHOD: process.env.AUTH_GOOGLE_METHOD === "true",
|
||||
PRIVATE_PATH: z.string().optional(),
|
||||
},
|
||||
client: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
|
||||
},
|
||||
runtimeEnv: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: version || "Unknown Version",
|
||||
|
||||
STORAGE_TYPE: process.env.STORAGE_TYPE,
|
||||
PROJECT_NAME: process.env.PROJECT_NAME,
|
||||
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
|
||||
PROJECT_URL: process.env.PROJECT_URL,
|
||||
PROJECT_SECRET: process.env.PROJECT_SECRET,
|
||||
|
||||
RETENTION_CRON: process.env.RETENTION_CRON,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
|
||||
PRIVATE_PATH: process.env.PRIVATE_PATH || path.join(process.cwd(), 'private')
|
||||
},
|
||||
SMTP_PASSWORD: process.env.SMTP_PASSWORD,
|
||||
SMTP_FROM: process.env.SMTP_FROM,
|
||||
SMTP_HOST: process.env.SMTP_HOST,
|
||||
SMTP_PORT: process.env.SMTP_PORT,
|
||||
SMTP_USER: process.env.SMTP_USER,
|
||||
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||
|
||||
RETENTION_CRON: process.env.RETENTION_CRON,
|
||||
|
||||
AUTH_OIDC_ID: process.env.AUTH_OIDC_ID,
|
||||
AUTH_OIDC_TITLE: process.env.AUTH_OIDC_TITLE,
|
||||
AUTH_OIDC_DESC: process.env.AUTH_OIDC_DESC,
|
||||
AUTH_OIDC_ICON: process.env.AUTH_OIDC_ICON,
|
||||
AUTH_OIDC_CLIENT: process.env.AUTH_OIDC_CLIENT,
|
||||
AUTH_OIDC_SECRET: process.env.AUTH_OIDC_SECRET,
|
||||
AUTH_OIDC_ISSUER_URL: process.env.AUTH_OIDC_ISSUER_URL,
|
||||
AUTH_OIDC_HOST: process.env.AUTH_OIDC_HOST,
|
||||
AUTH_OIDC_SCOPES: process.env.AUTH_OIDC_SCOPES,
|
||||
AUTH_OIDC_DISCOVERY_ENDPOINT: process.env.AUTH_OIDC_DISCOVERY_ENDPOINT,
|
||||
AUTH_OIDC_JWKS_ENDPOINT: process.env.AUTH_OIDC_JWKS_ENDPOINT,
|
||||
AUTH_OIDC_PKCE: process.env.AUTH_OIDC_PKCE,
|
||||
|
||||
AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
|
||||
AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
|
||||
|
||||
AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID,
|
||||
AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET,
|
||||
|
||||
AUTH_SOCIAL_ID: process.env.AUTH_SOCIAL_ID,
|
||||
AUTH_SOCIAL_TITLE: process.env.AUTH_SOCIAL_TITLE,
|
||||
AUTH_SOCIAL_DESC: process.env.AUTH_SOCIAL_DESC,
|
||||
AUTH_SOCIAL_ICON: process.env.AUTH_SOCIAL_ICON,
|
||||
AUTH_SOCIAL_CLIENT: process.env.AUTH_SOCIAL_CLIENT,
|
||||
AUTH_SOCIAL_SECRET: process.env.AUTH_SOCIAL_SECRET,
|
||||
|
||||
ALLOWED_GROUP: process.env.ALLOWED_GROUP,
|
||||
|
||||
AUTH_EMAIL_PASSWORD_ENABLED: process.env.AUTH_EMAIL_PASSWORD_ENABLED,
|
||||
AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED,
|
||||
AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED,
|
||||
|
||||
AUTH_SYNC_OIDC_ROLES_ON_LOGIN: process.env.AUTH_SYNC_OIDC_ROLES_ON_LOGIN,
|
||||
|
||||
AUTH_ROLE_MAP: process.env.AUTH_ROLE_MAP,
|
||||
|
||||
AUTH_ALLOW_LINKING: process.env.AUTH_ALLOW_LINKING,
|
||||
AUTH_ALLOW_UNLINKING: process.env.AUTH_ALLOW_UNLINKING,
|
||||
|
||||
PRIVATE_PATH:
|
||||
process.env.PRIVATE_PATH || path.join(process.cwd(), "private"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,148 +2,183 @@
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {MoreHorizontal, Trash2} from "lucide-react";
|
||||
import { Check, MoreHorizontal, Trash2, X } from "lucide-react";
|
||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||
import { StatusBadge } from "@/components/wrappers/common/status-badge";
|
||||
import {Restoration} from "@/db/schema/07_database";
|
||||
import {formatLocalizedDate} from "@/utils/date-formatting";
|
||||
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
||||
import { Restoration } from "@/db/schema/07_database";
|
||||
import { formatLocalizedDate } from "@/utils/date-formatting";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
deleteRestoreAction,
|
||||
rerunRestorationAction
|
||||
deleteRestoreAction,
|
||||
rerunRestorationAction,
|
||||
} from "@/features/dashboard/restore/restore.action";
|
||||
import {toast} from "sonner";
|
||||
import {TooltipCustom} from "@/components/wrappers/common/tooltip-custom";
|
||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { TooltipCustom } from "@/components/wrappers/common/tooltip-custom";
|
||||
import { MemberWithUser } from "@/db/schema/03_organization";
|
||||
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
|
||||
|
||||
export function restoreColumns(
|
||||
isAlreadyRestore: boolean,
|
||||
activeMember: MemberWithUser
|
||||
isAlreadyRestore: boolean,
|
||||
activeMember: MemberWithUser,
|
||||
): ColumnDef<Restoration>[] {
|
||||
return[
|
||||
return [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "Reference",
|
||||
accessorKey: "id",
|
||||
header: "Reference",
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created At",
|
||||
cell: ({ row }) => {
|
||||
return formatLocalizedDate(row.getValue("createdAt"))
|
||||
},
|
||||
accessorKey: "createdAt",
|
||||
header: "Created At",
|
||||
cell: ({ row }) => {
|
||||
return formatLocalizedDate(row.getValue("createdAt"));
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
return <StatusBadge status={row.getValue("status")} />;
|
||||
},
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
return <StatusBadge status={row.getValue("status")} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status");
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status");
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const rowData: Restoration = row.original;
|
||||
const queryClient = useQueryClient();
|
||||
const rowData: Restoration = row.original;
|
||||
|
||||
|
||||
const mutationDeleteRestore = useMutation({
|
||||
mutationFn: async () => {
|
||||
const restoration = await deleteRestoreAction({
|
||||
restorationId: rowData.id,
|
||||
});
|
||||
// @ts-ignore
|
||||
if (restoration.data.success) {
|
||||
// @ts-ignore
|
||||
toast.success(restoration.data.actionSuccess.message);
|
||||
queryClient.invalidateQueries({queryKey: ["database-data", rowData.databaseId]});
|
||||
} else {
|
||||
// @ts-ignore
|
||||
toast.error(restoration.data.actionError.message);
|
||||
}
|
||||
},
|
||||
const mutationDeleteRestore = useMutation({
|
||||
mutationFn: async () => {
|
||||
const restoration = await deleteRestoreAction({
|
||||
restorationId: rowData.id,
|
||||
});
|
||||
|
||||
const mutationRerunRestore = useMutation({
|
||||
mutationFn: async () => {
|
||||
const restoration = await rerunRestorationAction({
|
||||
restorationId: rowData.id,
|
||||
});
|
||||
// @ts-ignore
|
||||
if (restoration.data.success) {
|
||||
// @ts-ignore
|
||||
toast.success(restoration.data.actionSuccess.message);
|
||||
queryClient.invalidateQueries({queryKey: ["database-data", rowData.databaseId]});
|
||||
} else {
|
||||
// @ts-ignore
|
||||
toast.error(restoration.data.actionError.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
const handleDelete = async () => {
|
||||
await mutationDeleteRestore.mutateAsync();
|
||||
};
|
||||
|
||||
const handleRerunRestore = async () => {
|
||||
await mutationRerunRestore.mutateAsync();
|
||||
|
||||
// @ts-ignore
|
||||
if (restoration.data.success) {
|
||||
// @ts-ignore
|
||||
toast.success(restoration.data.actionSuccess.message);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["database-data", rowData.databaseId],
|
||||
});
|
||||
} else {
|
||||
// @ts-ignore
|
||||
toast.error(restoration.data.actionError.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const mutationRerunRestore = useMutation({
|
||||
mutationFn: async () => {
|
||||
const restoration = await rerunRestorationAction({
|
||||
restorationId: rowData.id,
|
||||
});
|
||||
// @ts-ignore
|
||||
if (restoration.data.success) {
|
||||
// @ts-ignore
|
||||
toast.success(restoration.data.actionSuccess.message);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["database-data", rowData.databaseId],
|
||||
});
|
||||
} else {
|
||||
// @ts-ignore
|
||||
toast.error(restoration.data.actionError.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{activeMember.role != "member" && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0" type="button" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4"/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
{rowData.backupStorageId && (
|
||||
<TooltipCustom disabled={isAlreadyRestore} text="Already a restoration waiting">
|
||||
<DropdownMenuItem
|
||||
disabled={mutationRerunRestore.isPending || isAlreadyRestore}
|
||||
onClick={async () => {
|
||||
await handleRerunRestore();
|
||||
}}
|
||||
>
|
||||
<ReloadIcon/> Rerun
|
||||
</DropdownMenuItem>
|
||||
</TooltipCustom>
|
||||
)}
|
||||
<DropdownMenuSeparator/>
|
||||
<DropdownMenuItem
|
||||
disabled={status == "waiting"}
|
||||
className="text-red-600"
|
||||
onClick={async () => {
|
||||
await handleDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2/> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
const handleDelete = async () => {
|
||||
await mutationDeleteRestore.mutateAsync();
|
||||
};
|
||||
|
||||
const handleRerunRestore = async () => {
|
||||
await mutationRerunRestore.mutateAsync();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{activeMember.role !== "member" && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
{rowData.backupStorageId && (
|
||||
<TooltipCustom
|
||||
disabled={isAlreadyRestore}
|
||||
text="Already a restoration waiting"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
disabled={
|
||||
mutationRerunRestore.isPending || isAlreadyRestore
|
||||
}
|
||||
onClick={async () => {
|
||||
await handleRerunRestore();
|
||||
}}
|
||||
>
|
||||
<ReloadIcon /> Rerun
|
||||
</DropdownMenuItem>
|
||||
</TooltipCustom>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<ButtonWithConfirm
|
||||
title="Delete Restoration"
|
||||
description={
|
||||
"Are you sure you want to delete this restoration?"
|
||||
}
|
||||
button={{
|
||||
main: {
|
||||
text: "Delete",
|
||||
variant: "ghost",
|
||||
icon: (
|
||||
<Trash2 className="text-red-500 size-4 mr-px" />
|
||||
),
|
||||
disabled: status === "waiting",
|
||||
className:
|
||||
"text-red-500 hover:text-red-500 p-0 h-auto has-[>svg]:px-0",
|
||||
},
|
||||
confirm: {
|
||||
className: "w-full",
|
||||
text: "Yes, delete",
|
||||
icon: <Check />,
|
||||
variant: "destructive",
|
||||
onClick: async () => {
|
||||
await handleDelete();
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
className: "w-full",
|
||||
text: "No, cancel",
|
||||
icon: <X />,
|
||||
variant: "outline",
|
||||
},
|
||||
}}
|
||||
isPending={mutationDeleteRestore.isPending}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
import type {EventPayload, DispatchResult} from '../types';
|
||||
|
||||
const getPriority = (level?: string): number => {
|
||||
switch (level) {
|
||||
case 'critical': return 5;
|
||||
case 'error': return 4;
|
||||
case 'warning': return 3;
|
||||
case 'info':
|
||||
default: return 2;
|
||||
}
|
||||
};
|
||||
|
||||
const formatData = (data: any): string => {
|
||||
if (!data || typeof data !== 'object') return '';
|
||||
return '\n\nDetails:\n' + Object.entries(data)
|
||||
.map(([key, value]) => {
|
||||
const stringVal = value !== null && typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
return `- ${key}: ${stringVal}`;
|
||||
})
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
const getTags = (level?: string): string[] => {
|
||||
const baseTags = ['floppy_disk'];
|
||||
|
||||
if (level === 'critical') baseTags.push('rotating_light');
|
||||
else if (level === 'error') baseTags.push('x');
|
||||
else if (level === 'warning') baseTags.push('warning');
|
||||
else baseTags.push('information_source');
|
||||
|
||||
return baseTags;
|
||||
};
|
||||
|
||||
export async function sendNtfy(
|
||||
config: { ntfyServerUrl?: string; ntfyTopic: string; ntfyToken?: string, ntfyUsername?: string, ntfyPassword?: string },
|
||||
payload: EventPayload
|
||||
@@ -11,9 +42,9 @@ export async function sendNtfy(
|
||||
const body = {
|
||||
topic: ntfyTopic,
|
||||
title: payload.title,
|
||||
message: payload.message + (payload.data ? `\n\nData:\n${JSON.stringify(payload.data, null, 2)}` : ''),
|
||||
priority: 1,
|
||||
tags: [payload.level === 'critical' ? 'rotating_light' : payload.level === 'warning' ? 'warning' : 'information_source'],
|
||||
message: payload.message + formatData(payload.data),
|
||||
priority: getPriority(payload.level),
|
||||
tags: getTags(payload.level),
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
|
||||
@@ -1,49 +1,62 @@
|
||||
import type {EventPayload, DispatchResult} from '../types';
|
||||
import type { EventPayload, DispatchResult } from "../types";
|
||||
|
||||
export async function sendTelegram(
|
||||
config: { telegramBotToken: string; telegramChatId: string },
|
||||
payload: EventPayload
|
||||
config: {
|
||||
telegramBotToken: string;
|
||||
telegramChatId: string;
|
||||
telegramTopicId?: string;
|
||||
},
|
||||
payload: EventPayload,
|
||||
): Promise<DispatchResult> {
|
||||
const {telegramBotToken, telegramChatId} = config;
|
||||
const { telegramBotToken, telegramChatId, telegramTopicId } = config;
|
||||
|
||||
// Helper to escape HTML characters
|
||||
const escapeHtml = (unsafe: string) => {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
};
|
||||
// Helper to escape HTML characters
|
||||
const escapeHtml = (unsafe: string) => {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
};
|
||||
|
||||
const title = escapeHtml(payload.title);
|
||||
const message = escapeHtml(payload.message);
|
||||
const level = escapeHtml(payload.level.toUpperCase());
|
||||
const dataString = payload.data ? escapeHtml(JSON.stringify(payload.data, null, 2).substring(0, 1000)) : '';
|
||||
const title = escapeHtml(payload.title);
|
||||
const message = escapeHtml(payload.message);
|
||||
const level = escapeHtml(payload.level.toUpperCase());
|
||||
const dataString = payload.data
|
||||
? escapeHtml(JSON.stringify(payload.data, null, 2).substring(0, 1000))
|
||||
: "";
|
||||
|
||||
const text = `<b>${title}</b>\n\n${message}\n\nLevel: <code>${level}</code>${payload.data ? `\n\nData:\n<pre>${dataString}</pre>` : ''}`;
|
||||
const text = `<b>${title}</b>\n\n${message}\n\nLevel: <code>${level}</code>${payload.data ? `\n\nData:\n<pre>${dataString}</pre>` : ""}`;
|
||||
|
||||
const body = {
|
||||
chat_id: telegramChatId,
|
||||
text,
|
||||
parse_mode: 'HTML',
|
||||
};
|
||||
const body: Record<string, any> = {
|
||||
chat_id: telegramChatId,
|
||||
text,
|
||||
parse_mode: "HTML",
|
||||
};
|
||||
|
||||
const res = await fetch(`https://api.telegram.org/bot${telegramBotToken}/sendMessage`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
});
|
||||
if (telegramTopicId) {
|
||||
body.message_thread_id = Number(telegramTopicId);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Telegram error: ${res.status} ${err}`);
|
||||
}
|
||||
const res = await fetch(
|
||||
`https://api.telegram.org/bot${telegramBotToken}/sendMessage`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: 'telegram',
|
||||
message: 'Sent to Telegram',
|
||||
response: await res.text(),
|
||||
};
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Telegram error: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "telegram",
|
||||
message: "Sent to Telegram",
|
||||
response: await res.text(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,12 +97,14 @@ export async function dispatchStorage(
|
||||
}
|
||||
|
||||
|
||||
return await dispatchViaProvider(
|
||||
const dispatchResult = await dispatchViaProvider(
|
||||
channel.provider as StorageProviderKind,
|
||||
channel.config,
|
||||
input,
|
||||
);
|
||||
|
||||
console.log(dispatchResult);
|
||||
return dispatchResult;
|
||||
|
||||
} catch (err: any) {
|
||||
return {
|
||||
|
||||
@@ -1,141 +1,161 @@
|
||||
"use server"
|
||||
import {mkdir, unlink} from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {StorageDeleteInput, StorageGetInput, StorageMetaData, StorageResult, StorageUploadInput} from '../types';
|
||||
"use server";
|
||||
import { mkdir, unlink } from "fs/promises";
|
||||
import path from "path";
|
||||
import {
|
||||
StorageDeleteInput,
|
||||
StorageGetInput,
|
||||
StorageMetaData,
|
||||
StorageResult,
|
||||
StorageUploadInput,
|
||||
} from "../types";
|
||||
import fs from "node:fs";
|
||||
import {generateFileUrl} from "@/features/storages/helpers";
|
||||
import {Readable} from "node:stream";
|
||||
import {env} from "@/env.mjs";
|
||||
import { generateFileUrl } from "@/features/storages/helpers";
|
||||
import { Readable } from "node:stream";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
const BASE_DIR = path.join(env.PRIVATE_PATH, '/uploads')
|
||||
const BASE_DIR = path.join(env.PRIVATE_PATH, "/uploads");
|
||||
|
||||
export async function uploadLocal(
|
||||
config: { baseDir?: string },
|
||||
input: { data: StorageUploadInput; metadata?: StorageMetaData }
|
||||
config: { baseDir?: string },
|
||||
input: { data: StorageUploadInput; metadata?: StorageMetaData },
|
||||
): Promise<StorageResult> {
|
||||
const base = config.baseDir ? path.join(process.cwd(), config.baseDir ?? "") : BASE_DIR;
|
||||
const base = config.baseDir
|
||||
? path.join(process.cwd(), config.baseDir ?? "")
|
||||
: BASE_DIR;
|
||||
|
||||
const fullPath = path.join(base, input.data.path);
|
||||
const dir = path.dirname(fullPath);
|
||||
const fullPath = path.join(base, input.data.path);
|
||||
const dir = path.dirname(fullPath);
|
||||
|
||||
await mkdir(dir, { recursive: true });
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
try {
|
||||
const file = input.data.file;
|
||||
if (Buffer.isBuffer(file)) {
|
||||
await fs.promises.writeFile(fullPath, input.data.file);
|
||||
} else if (file instanceof Readable) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const writable = fs.createWriteStream(fullPath);
|
||||
file.pipe(writable);
|
||||
writable.on("finish", resolve);
|
||||
writable.on("error", reject);
|
||||
});
|
||||
} else {
|
||||
return { success: false, provider: "local", error: "Unsupported file type. Must be Buffer or ReadableStream" };
|
||||
}
|
||||
|
||||
if (input.data.url) {
|
||||
const url = await generateFileUrl(input);
|
||||
if (!url) {
|
||||
return { success: false, provider: "local", response: "Unable to get URL" };
|
||||
}
|
||||
return { success: true, provider: "local", url };
|
||||
}
|
||||
|
||||
return { success: true, provider: "local" };
|
||||
} catch (err: any) {
|
||||
try { await unlink(fullPath); } catch {}
|
||||
return { success: false, provider: "local", error: err.message || "Upload failed" };
|
||||
try {
|
||||
const file = input.data.file;
|
||||
if (Buffer.isBuffer(file)) {
|
||||
await fs.promises.writeFile(fullPath, input.data.file);
|
||||
} else if (file instanceof Readable) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const writable = fs.createWriteStream(fullPath);
|
||||
file.pipe(writable);
|
||||
writable.on("finish", resolve);
|
||||
writable.on("error", reject);
|
||||
});
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
provider: "local",
|
||||
error: "Unsupported file type. Must be Buffer or ReadableStream",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.data.url) {
|
||||
const url = await generateFileUrl(input);
|
||||
if (!url) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "local",
|
||||
response: "Unable to get URL",
|
||||
};
|
||||
}
|
||||
return { success: true, provider: "local", url };
|
||||
}
|
||||
|
||||
return { success: true, provider: "local" };
|
||||
} catch (err: any) {
|
||||
try {
|
||||
await unlink(fullPath);
|
||||
} catch {}
|
||||
return {
|
||||
success: false,
|
||||
provider: "local",
|
||||
error: err.message || "Upload failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLocal(
|
||||
config: { baseDir?: string },
|
||||
input: { data: StorageGetInput; metadata: StorageMetaData }
|
||||
config: { baseDir?: string },
|
||||
input: { data: StorageGetInput; metadata: StorageMetaData },
|
||||
): Promise<StorageResult> {
|
||||
const base = config.baseDir
|
||||
? path.join(process.cwd(), config.baseDir ?? "")
|
||||
: BASE_DIR;
|
||||
const filePath = path.join(base, input.data.path);
|
||||
|
||||
const base = config.baseDir ? path.join(process.cwd(), config.baseDir ?? "") : BASE_DIR;
|
||||
const filePath = path.join(base, input.data.path);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error("File not found at:", filePath);
|
||||
return {
|
||||
success: false,
|
||||
provider: "local",
|
||||
error: "File not found",
|
||||
};
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error("File not found at:", filePath);
|
||||
return {
|
||||
success: false,
|
||||
provider: "local",
|
||||
error: "File not found",
|
||||
};
|
||||
}
|
||||
let fileStream: fs.ReadStream | undefined;
|
||||
|
||||
let fileStream: fs.ReadStream | undefined;
|
||||
try {
|
||||
fileStream = fs.createReadStream(filePath);
|
||||
} catch (err: any) {
|
||||
console.error("Error creating read stream:", err);
|
||||
return {
|
||||
success: false,
|
||||
provider: "local",
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
fileStream = fs.createReadStream(filePath);
|
||||
} catch (err: any) {
|
||||
console.error("Error creating read stream:", err);
|
||||
return {
|
||||
success: false,
|
||||
provider: "local",
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.data.signedUrl) {
|
||||
const url = await generateFileUrl(input);
|
||||
if (!url) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "local",
|
||||
error: "Unable to generate signed URL",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "local",
|
||||
file: fileStream,
|
||||
url,
|
||||
};
|
||||
if (input.data.signedUrl) {
|
||||
const url = await generateFileUrl(input);
|
||||
if (!url) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "local",
|
||||
error: "Unable to generate signed URL",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "local",
|
||||
file: fileStream,
|
||||
success: true,
|
||||
provider: "local",
|
||||
file: fileStream,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "local",
|
||||
file: fileStream,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function deleteLocal(
|
||||
config: { baseDir?: string },
|
||||
input: { data: StorageDeleteInput, metadata?: StorageMetaData }
|
||||
config: { baseDir?: string },
|
||||
input: { data: StorageDeleteInput; metadata?: StorageMetaData },
|
||||
): Promise<StorageResult> {
|
||||
const base = config.baseDir
|
||||
? path.join(process.cwd(), config.baseDir ?? "")
|
||||
: BASE_DIR;
|
||||
const fullPath = path.join(base, input.data.path);
|
||||
|
||||
const base = config.baseDir ? path.join(process.cwd(), config.baseDir ?? "") : BASE_DIR;
|
||||
const fullPath = path.join(base, input.data.path);
|
||||
|
||||
await unlink(fullPath);
|
||||
return {
|
||||
success: true,
|
||||
provider: 'local',
|
||||
};
|
||||
await unlink(fullPath);
|
||||
return {
|
||||
success: true,
|
||||
provider: "local",
|
||||
};
|
||||
}
|
||||
|
||||
export async function pingLocal(
|
||||
config: { baseDir?: string }
|
||||
): Promise<StorageResult> {
|
||||
export async function pingLocal(config: {
|
||||
baseDir?: string;
|
||||
}): Promise<StorageResult> {
|
||||
const base = path.join(process.cwd(), config.baseDir ?? "") || BASE_DIR;
|
||||
const fullPath = path.join(base, "ping.txt");
|
||||
|
||||
const base = path.join(process.cwd(), config.baseDir ?? "") || BASE_DIR;
|
||||
const fullPath = path.join(base, "ping.txt");
|
||||
|
||||
await fs.promises.writeFile(fullPath, "ping");
|
||||
await fs.promises.readFile(fullPath);
|
||||
await fs.promises.unlink(fullPath);
|
||||
return {
|
||||
success: true,
|
||||
provider: 'local',
|
||||
response: "Local storage OK"
|
||||
};
|
||||
|
||||
}
|
||||
await fs.promises.writeFile(fullPath, "ping");
|
||||
await fs.promises.readFile(fullPath);
|
||||
await fs.promises.unlink(fullPath);
|
||||
return {
|
||||
success: true,
|
||||
provider: "local",
|
||||
response: "Local storage OK",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ async function getS3Client(config: S3Config) {
|
||||
region: config.region ?? "us-east-1",
|
||||
accessKey: config.accessKey,
|
||||
secretKey: config.secretKey,
|
||||
port: config.port ?? 443,
|
||||
port: config.port ? Number(config.port) : 443,
|
||||
useSSL: config.ssl ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ import {createAuthClient} from "better-auth/react";
|
||||
|
||||
import {adminClient, inferAdditionalFields, organizationClient, twoFactorClient} from "better-auth/client/plugins";
|
||||
import {ac, user, admin as adminRole, pending, superadmin, orgAdmin, orgMember, orgOwner} from "./permissions";
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import type {auth} from "@/lib/auth/auth";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import { ssoClient } from "@better-auth/sso/client";
|
||||
import { passkeyClient } from "@better-auth/passkey/client"
|
||||
|
||||
const res = await fetch(`${getServerUrl()}/api/config`);
|
||||
const {PROJECT_URL} = await res.json();
|
||||
@@ -12,7 +14,9 @@ const {PROJECT_URL} = await res.json();
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: PROJECT_URL,
|
||||
plugins: [
|
||||
passkeyClient(),
|
||||
twoFactorClient(),
|
||||
ssoClient(),
|
||||
organizationClient({
|
||||
ac,
|
||||
roles: {
|
||||
@@ -35,4 +39,4 @@ export const authClient = createAuthClient({
|
||||
|
||||
});
|
||||
|
||||
export const {signIn, signOut, signUp, useSession, listAccounts, admin, requestPasswordReset} = authClient;
|
||||
export const { signIn, signOut, signUp, deleteUser, useSession, listAccounts, passkey, admin, twoFactor, requestPasswordReset, sso } = authClient;
|
||||
+659
-422
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { getOidcProviders } from "./oidc";
|
||||
import { getOAuthProviders } from "./oauth";
|
||||
import * as BetterAuthSocialProviders from "better-auth/social-providers";
|
||||
|
||||
export interface AuthProviderConfig {
|
||||
id: string;
|
||||
isActive: boolean;
|
||||
name?: string;
|
||||
icon: string;
|
||||
isManual?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
type: "social" | "sso" | "credential" | "passkey";
|
||||
allowLinking?: boolean;
|
||||
allowUnlinking?: boolean;
|
||||
}
|
||||
|
||||
const oidcProviders = getOidcProviders();
|
||||
const oauthProviders = getOAuthProviders();
|
||||
|
||||
const availableSocialProviders = Object.keys(BetterAuthSocialProviders);
|
||||
|
||||
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
|
||||
{
|
||||
id: "credential",
|
||||
isActive: env.AUTH_EMAIL_PASSWORD_ENABLED === "true",
|
||||
name: "Password",
|
||||
icon: "lucide:lock",
|
||||
title: "Password",
|
||||
description: "Standard email and password login.",
|
||||
isManual: true,
|
||||
type: "credential",
|
||||
allowLinking: true,
|
||||
allowUnlinking: true,
|
||||
},
|
||||
...oauthProviders.map((p) => {
|
||||
const isSupported = availableSocialProviders.includes(p.id.toLowerCase());
|
||||
|
||||
if (!isSupported) {
|
||||
console.warn(`Provider ${p.id} is not supported. Skipping...`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
isActive: isSupported,
|
||||
name: p.title,
|
||||
icon: p.icon,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
isManual: false,
|
||||
type: "social" as const,
|
||||
allowLinking: p.allowLinking,
|
||||
allowUnlinking: p.allowUnlinking,
|
||||
};
|
||||
}),
|
||||
...oidcProviders.map((p) => ({
|
||||
id: p.id,
|
||||
isActive: true,
|
||||
name: p.title,
|
||||
icon: p.icon,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
isManual: false,
|
||||
type: "sso" as const,
|
||||
allowLinking: p.allowLinking,
|
||||
allowUnlinking: p.allowUnlinking,
|
||||
})),
|
||||
{
|
||||
id: "passkey",
|
||||
isActive: env.AUTH_PASSKEY_ENABLED === "true",
|
||||
name: "Passkey",
|
||||
icon: "lucide:fingerprint",
|
||||
title: "Passkey",
|
||||
description: "Sign in with your passkey.",
|
||||
isManual: false,
|
||||
type: "passkey",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,155 @@
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
export interface OAuthProvider {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
client: string;
|
||||
secret: string;
|
||||
allowedGroup?: string;
|
||||
roleMap?: string;
|
||||
defaultRole?: string;
|
||||
allowLinking: boolean;
|
||||
allowUnlinking: boolean;
|
||||
appleBundleIdentifier?: string;
|
||||
}
|
||||
|
||||
const PROVIDER_ICONS: Record<string, string> = {
|
||||
google: "logos:google-icon",
|
||||
github: "logos:github-icon",
|
||||
gitlab: "logos:gitlab-icon",
|
||||
discord: "logos:discord-icon",
|
||||
facebook: "logos:facebook",
|
||||
twitter: "logos:twitter",
|
||||
x: "logos:x",
|
||||
linkedin: "logos:linkedin-icon",
|
||||
apple: "logos:apple",
|
||||
microsoft: "logos:microsoft-icon",
|
||||
twitch: "logos:twitch",
|
||||
spotify: "logos:spotify-icon",
|
||||
slack: "logos:slack-icon",
|
||||
tiktok: "logos:tiktok-icon",
|
||||
figma: "logos:figma",
|
||||
dropbox: "logos:dropbox",
|
||||
notion: "logos:notion-icon",
|
||||
paypal: "logos:paypal",
|
||||
reddit: "logos:reddit-icon",
|
||||
salesforce: "logos:salesforce",
|
||||
vercel: "logos:vercel-icon",
|
||||
zoom: "logos:zoom-icon",
|
||||
altassian: "logos:jira",
|
||||
};
|
||||
|
||||
function getProviderIcon(providerId: string, envIcon?: string): string {
|
||||
if (envIcon) return envIcon;
|
||||
|
||||
const normalizedId = providerId.toLowerCase();
|
||||
|
||||
if (PROVIDER_ICONS[normalizedId]) {
|
||||
return PROVIDER_ICONS[normalizedId];
|
||||
}
|
||||
|
||||
return `lucide:building`;
|
||||
}
|
||||
|
||||
export function getOAuthProviders(): OAuthProvider[] {
|
||||
const providers: OAuthProvider[] = [];
|
||||
|
||||
if (env.AUTH_GOOGLE_ID && env.AUTH_GOOGLE_SECRET) {
|
||||
providers.push({
|
||||
id: "google",
|
||||
title: "Google",
|
||||
description: "Sign in with your Google account.",
|
||||
icon: getProviderIcon("google"),
|
||||
client: env.AUTH_GOOGLE_ID,
|
||||
secret: env.AUTH_GOOGLE_SECRET,
|
||||
allowedGroup: env.ALLOWED_GROUP,
|
||||
roleMap: env.AUTH_ROLE_MAP ?? undefined,
|
||||
defaultRole: env.AUTH_DEFAULT_ROLE ?? "pending",
|
||||
allowLinking: env.AUTH_ALLOW_LINKING !== "false",
|
||||
allowUnlinking: env.AUTH_ALLOW_UNLINKING !== "false",
|
||||
});
|
||||
}
|
||||
|
||||
if (env.AUTH_GITHUB_ID && env.AUTH_GITHUB_SECRET) {
|
||||
providers.push({
|
||||
id: "github",
|
||||
title: "GitHub",
|
||||
description: "Sign in with your GitHub account.",
|
||||
icon: getProviderIcon("github"),
|
||||
client: env.AUTH_GITHUB_ID,
|
||||
secret: env.AUTH_GITHUB_SECRET,
|
||||
allowedGroup: env.ALLOWED_GROUP,
|
||||
roleMap: env.AUTH_ROLE_MAP,
|
||||
defaultRole: env.AUTH_DEFAULT_ROLE,
|
||||
allowLinking: env.AUTH_ALLOW_LINKING !== "false",
|
||||
allowUnlinking: env.AUTH_ALLOW_UNLINKING !== "false",
|
||||
});
|
||||
}
|
||||
|
||||
if (env.AUTH_SOCIAL_CLIENT && env.AUTH_SOCIAL_ID) {
|
||||
providers.push({
|
||||
id: env.AUTH_SOCIAL_ID,
|
||||
title: env.AUTH_SOCIAL_TITLE || "OAuth",
|
||||
description: env.AUTH_SOCIAL_DESC || "Sign in with your OAuth account.",
|
||||
icon: env.AUTH_SOCIAL_ICON || "lucide:building",
|
||||
client: env.AUTH_SOCIAL_CLIENT,
|
||||
secret: env.AUTH_SOCIAL_SECRET || "",
|
||||
allowedGroup: env.ALLOWED_GROUP,
|
||||
roleMap: env.AUTH_ROLE_MAP,
|
||||
defaultRole: env.AUTH_DEFAULT_ROLE,
|
||||
allowLinking: env.AUTH_ALLOW_LINKING !== "false",
|
||||
allowUnlinking: env.AUTH_ALLOW_UNLINKING !== "false",
|
||||
appleBundleIdentifier: env.AUTH_SOCIAL_APPLE_APP_BUNDLE_IDENTIFIER,
|
||||
});
|
||||
}
|
||||
|
||||
const prefixes = new Set<string>();
|
||||
Object.keys(process.env).forEach((key) => {
|
||||
const match = key.match(/^AUTH_SOCIAL_(.+)_CLIENT$/);
|
||||
if (match) {
|
||||
prefixes.add(match[1]);
|
||||
}
|
||||
});
|
||||
|
||||
prefixes.forEach((prefix) => {
|
||||
const client = process.env[`AUTH_SOCIAL_${prefix}_CLIENT`];
|
||||
if (!client) return;
|
||||
|
||||
const providerId =
|
||||
prefix.toLowerCase() === "x" ? "twitter" : prefix.toLowerCase();
|
||||
|
||||
const envTitle = process.env[`AUTH_SOCIAL_${prefix}_TITLE`];
|
||||
const envDesc = process.env[`AUTH_SOCIAL_${prefix}_DESC`];
|
||||
const envIcon = process.env[`AUTH_SOCIAL_${prefix}_ICON`];
|
||||
const envSecret = process.env[`AUTH_SOCIAL_${prefix}_SECRET`];
|
||||
const envAllowedGroup = process.env[`AUTH_SOCIAL_${prefix}_ALLOWED_GROUP`];
|
||||
const envRoleMap = process.env[`AUTH_SOCIAL_${prefix}_ROLE_MAP`];
|
||||
const envDefaultRole = process.env[`AUTH_SOCIAL_${prefix}_DEFAULT_ROLE`];
|
||||
|
||||
const envAppleBundleId =
|
||||
process.env[`AUTH_SOCIAL_APPLE_APP_BUNDLE_IDENTIFIER`];
|
||||
|
||||
providers.push({
|
||||
id: providerId,
|
||||
title:
|
||||
envTitle ||
|
||||
prefix.charAt(0).toUpperCase() + prefix.slice(1).toLowerCase(),
|
||||
description:
|
||||
envDesc ||
|
||||
`Sign in with ${prefix.charAt(0).toUpperCase() + prefix.slice(1).toLowerCase()}`,
|
||||
icon: getProviderIcon(providerId, envIcon),
|
||||
client: client,
|
||||
secret: envSecret || "",
|
||||
allowedGroup: envAllowedGroup || env.ALLOWED_GROUP,
|
||||
roleMap: envRoleMap,
|
||||
defaultRole: envDefaultRole,
|
||||
allowLinking: env.AUTH_ALLOW_LINKING !== "false",
|
||||
allowUnlinking: env.AUTH_ALLOW_UNLINKING !== "false",
|
||||
appleBundleIdentifier: envAppleBundleId,
|
||||
});
|
||||
});
|
||||
|
||||
return providers;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
export interface OIDCProvider {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
client: string;
|
||||
secret: string;
|
||||
issuerUrl: string;
|
||||
host: string;
|
||||
scopes?: string;
|
||||
discoveryEndpoint?: string;
|
||||
jwksEndpoint?: string;
|
||||
pkce: boolean;
|
||||
allowedGroup?: string;
|
||||
roleMap?: string;
|
||||
defaultRole?: string;
|
||||
allowLinking: boolean;
|
||||
allowUnlinking: boolean;
|
||||
}
|
||||
|
||||
export function getOidcProviders(): OIDCProvider[] {
|
||||
const providers: OIDCProvider[] = [];
|
||||
|
||||
if (
|
||||
env.AUTH_OIDC_CLIENT &&
|
||||
(env.AUTH_OIDC_ISSUER_URL || env.AUTH_OIDC_DISCOVERY_ENDPOINT)
|
||||
) {
|
||||
providers.push({
|
||||
id: env.AUTH_OIDC_ID || "oidc",
|
||||
title: env.AUTH_OIDC_TITLE || "SSO",
|
||||
description: env.AUTH_OIDC_DESC || "Sign in with your SSO account.",
|
||||
icon: env.AUTH_OIDC_ICON || "lucide:building",
|
||||
client: env.AUTH_OIDC_CLIENT,
|
||||
secret: env.AUTH_OIDC_SECRET || "",
|
||||
issuerUrl: env.AUTH_OIDC_ISSUER_URL || "",
|
||||
host: env.AUTH_OIDC_HOST || "",
|
||||
scopes: env.AUTH_OIDC_SCOPES,
|
||||
discoveryEndpoint: env.AUTH_OIDC_DISCOVERY_ENDPOINT,
|
||||
jwksEndpoint: env.AUTH_OIDC_JWKS_ENDPOINT,
|
||||
pkce: env.AUTH_OIDC_PKCE === "true",
|
||||
allowedGroup: env.ALLOWED_GROUP,
|
||||
roleMap: env.AUTH_ROLE_MAP,
|
||||
defaultRole: env.AUTH_DEFAULT_ROLE,
|
||||
allowLinking: env.AUTH_ALLOW_LINKING !== "false",
|
||||
allowUnlinking: env.AUTH_ALLOW_UNLINKING !== "false",
|
||||
});
|
||||
}
|
||||
|
||||
const prefixes = new Set<string>();
|
||||
Object.keys(process.env).forEach((key) => {
|
||||
const match = key.match(/^AUTH_OIDC_(.+)_CLIENT$/);
|
||||
if (match) {
|
||||
prefixes.add(match[1]);
|
||||
}
|
||||
});
|
||||
|
||||
prefixes.forEach((prefix) => {
|
||||
const client = process.env[`AUTH_OIDC_${prefix}_CLIENT`];
|
||||
const issuer = process.env[`AUTH_OIDC_${prefix}_ISSUER_URL`];
|
||||
const discovery = process.env[`AUTH_OIDC_${prefix}_DISCOVERY_ENDPOINT`];
|
||||
|
||||
if (!client || (!issuer && !discovery)) return;
|
||||
|
||||
providers.push({
|
||||
id: process.env[`AUTH_OIDC_${prefix}_ID`] || prefix.toLowerCase(),
|
||||
title: process.env[`AUTH_OIDC_${prefix}_TITLE`] || prefix,
|
||||
description:
|
||||
process.env[`AUTH_OIDC_${prefix}_DESC`] || `Sign in with ${prefix}`,
|
||||
icon: process.env[`AUTH_OIDC_${prefix}_ICON`] || "lucide:building",
|
||||
client: client,
|
||||
secret: process.env[`AUTH_OIDC_${prefix}_SECRET`] || "",
|
||||
issuerUrl: issuer || "",
|
||||
host: process.env[`AUTH_OIDC_${prefix}_HOST`] || "",
|
||||
scopes: process.env[`AUTH_OIDC_${prefix}_SCOPES`],
|
||||
discoveryEndpoint: discovery,
|
||||
jwksEndpoint: process.env[`AUTH_OIDC_${prefix}_JWKS_ENDPOINT`],
|
||||
pkce: process.env[`AUTH_OIDC_${prefix}_PKCE`] === "true",
|
||||
allowedGroup:
|
||||
process.env[`AUTH_OIDC_${prefix}_ALLOWED_GROUP`] ||
|
||||
process.env.ALLOWED_GROUP,
|
||||
roleMap: process.env[`AUTH_OIDC_${prefix}_ROLE_MAP`],
|
||||
defaultRole: process.env[`AUTH_OIDC_${prefix}_DEFAULT_ROLE`],
|
||||
allowLinking:
|
||||
process.env[`AUTH_OIDC_${prefix}_ALLOW_LINKING`] !== "false",
|
||||
allowUnlinking:
|
||||
process.env[`AUTH_OIDC_${prefix}_ALLOW_UNLINKING`] !== "false",
|
||||
});
|
||||
});
|
||||
|
||||
return providers;
|
||||
}
|
||||
+42
-46
@@ -1,76 +1,72 @@
|
||||
import {createAccessControl} from "better-auth/plugins/access";
|
||||
import {defaultStatements, adminAc} from "better-auth/plugins/admin/access";
|
||||
import { createAccessControl } from "better-auth/plugins/access";
|
||||
import { defaultStatements, adminAc } from "better-auth/plugins/admin/access";
|
||||
import {
|
||||
defaultStatements as orgDefaultStatements,
|
||||
adminAc as orgAdminAc,
|
||||
ownerAc as orgOwnerAc,
|
||||
memberAc as orgMemberAc
|
||||
defaultStatements as orgDefaultStatements,
|
||||
adminAc as orgAdminAc,
|
||||
ownerAc as orgOwnerAc,
|
||||
memberAc as orgMemberAc,
|
||||
} from "better-auth/plugins/organization/access";
|
||||
|
||||
const statement = {
|
||||
...defaultStatements,
|
||||
...orgDefaultStatements,
|
||||
project: ["create", "list", "update", "delete"],
|
||||
database: ["create", "list", "update", "delete", "backup"],
|
||||
agent: ["create", "list", "update", "delete"],
|
||||
|
||||
...defaultStatements,
|
||||
...orgDefaultStatements,
|
||||
project: ["create", "list", "update", "delete"],
|
||||
database: ["create", "list", "update", "delete", "backup"],
|
||||
agent: ["create", "list", "update", "delete"],
|
||||
} as const;
|
||||
|
||||
const ac = createAccessControl(statement);
|
||||
|
||||
|
||||
const superadmin = ac.newRole({
|
||||
project: ["create", "list", "update", "delete"],
|
||||
database: ["create", "list", "update", "delete"],
|
||||
agent: ["create", "list", "update", "delete"],
|
||||
...adminAc.statements,
|
||||
...orgMemberAc.statements,
|
||||
...orgAdminAc.statements,
|
||||
...orgOwnerAc.statements,
|
||||
project: ["create", "list", "update", "delete"],
|
||||
database: ["create", "list", "update", "delete"],
|
||||
agent: ["create", "list", "update", "delete"],
|
||||
...adminAc.statements,
|
||||
...orgMemberAc.statements,
|
||||
...orgAdminAc.statements,
|
||||
...orgOwnerAc.statements,
|
||||
});
|
||||
|
||||
const admin = ac.newRole({
|
||||
project: ["create", "list", "update", "delete"],
|
||||
database: ["create", "list", "update", "delete"],
|
||||
agent: ["create", "list", "update", "delete"],
|
||||
...adminAc.statements,
|
||||
...orgMemberAc.statements,
|
||||
...orgAdminAc.statements,
|
||||
project: ["create", "list", "update", "delete"],
|
||||
database: ["create", "list", "update", "delete"],
|
||||
agent: ["create", "list", "update", "delete"],
|
||||
...adminAc.statements,
|
||||
...orgMemberAc.statements,
|
||||
...orgAdminAc.statements,
|
||||
});
|
||||
|
||||
const user = ac.newRole({
|
||||
project: [],
|
||||
database: [],
|
||||
agent: [],
|
||||
project: [],
|
||||
database: [],
|
||||
agent: [],
|
||||
});
|
||||
|
||||
const pending = ac.newRole({
|
||||
project: [],
|
||||
database: [],
|
||||
agent: [],
|
||||
project: [],
|
||||
database: [],
|
||||
agent: [],
|
||||
});
|
||||
|
||||
//org
|
||||
const orgMember = ac.newRole({
|
||||
project: ["list"],
|
||||
database: ["list"],
|
||||
agent: ["list"],
|
||||
...orgMemberAc.statements,
|
||||
project: ["list"],
|
||||
database: ["list"],
|
||||
agent: ["list"],
|
||||
...orgMemberAc.statements,
|
||||
});
|
||||
|
||||
const orgAdmin = ac.newRole({
|
||||
project: ["create", "update"],
|
||||
...orgMemberAc.statements,
|
||||
...orgAdminAc.statements,
|
||||
project: ["create", "update"],
|
||||
...orgMemberAc.statements,
|
||||
...orgAdminAc.statements,
|
||||
});
|
||||
|
||||
const orgOwner = ac.newRole({
|
||||
project: ["create", "update", "delete"],
|
||||
...orgMemberAc.statements,
|
||||
...orgAdminAc.statements,
|
||||
...orgOwnerAc.statements,
|
||||
|
||||
project: ["create", "update", "delete"],
|
||||
...orgMemberAc.statements,
|
||||
...orgAdminAc.statements,
|
||||
...orgOwnerAc.statements,
|
||||
});
|
||||
|
||||
|
||||
export {ac, admin, superadmin, user, pending, orgAdmin, orgMember, orgOwner};
|
||||
export { ac, admin, superadmin, user, pending, orgAdmin, orgMember, orgOwner };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user