mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-11 07:26:39 +00:00
move all components to components dir
This commit is contained in:
48
src/components/AccessPageHeaderAndNav.tsx
Normal file
48
src/components/AccessPageHeaderAndNav.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface AccessPageHeaderAndNavProps {
|
||||
children: React.ReactNode;
|
||||
hasInvitations: boolean;
|
||||
}
|
||||
|
||||
export default function AccessPageHeaderAndNav({
|
||||
children,
|
||||
hasInvitations
|
||||
}: AccessPageHeaderAndNavProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: t('users'),
|
||||
href: `/{orgId}/settings/access/users`
|
||||
},
|
||||
{
|
||||
title: t('roles'),
|
||||
href: `/{orgId}/settings/access/roles`
|
||||
}
|
||||
];
|
||||
|
||||
if (hasInvitations) {
|
||||
navItems.push({
|
||||
title: t('invite'),
|
||||
href: `/{orgId}/settings/access/invitations`
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t('accessUsersRoles')}
|
||||
description={t('accessUsersRolesDescription')}
|
||||
/>
|
||||
|
||||
<HorizontalTabs items={navItems}>
|
||||
{children}
|
||||
</HorizontalTabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
160
src/components/AccessToken.tsx
Normal file
160
src/components/AccessToken.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@app/components/ui/card";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { AuthWithAccessTokenResponse } from "@server/routers/resource";
|
||||
import { AxiosResponse } from "axios";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type AccessTokenProps = {
|
||||
token: string;
|
||||
resourceId?: number;
|
||||
};
|
||||
|
||||
export default function AccessToken({
|
||||
token,
|
||||
resourceId
|
||||
}: AccessTokenProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
function appendRequestToken(url: string, token: string) {
|
||||
const fullUrl = new URL(url);
|
||||
fullUrl.searchParams.append(
|
||||
env.server.resourceSessionRequestParam,
|
||||
token
|
||||
);
|
||||
return fullUrl.toString();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let accessTokenId = "";
|
||||
let accessToken = "";
|
||||
|
||||
const parts = token.split(".");
|
||||
|
||||
if (parts.length === 2) {
|
||||
accessTokenId = parts[0];
|
||||
accessToken = parts[1];
|
||||
} else if (parts.length === 1) {
|
||||
accessToken = parts[0];
|
||||
} else {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
async function checkSHA256() {
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<AuthWithAccessTokenResponse>
|
||||
>(`/auth/access-token`, {
|
||||
accessToken,
|
||||
accessTokenId
|
||||
});
|
||||
|
||||
if (res.data.data.session) {
|
||||
setIsValid(true);
|
||||
window.location.href = appendRequestToken(
|
||||
res.data.data.redirectUrl!,
|
||||
res.data.data.session
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(t('accessTokenError'), e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<AuthWithAccessTokenResponse>
|
||||
>(`/auth/resource/${resourceId}/access-token`, {
|
||||
accessToken,
|
||||
accessTokenId
|
||||
});
|
||||
|
||||
if (res.data.data.session) {
|
||||
setIsValid(true);
|
||||
window.location.href = appendRequestToken(
|
||||
res.data.data.redirectUrl!,
|
||||
res.data.data.session
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(t('accessTokenError'), e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!accessTokenId) {
|
||||
// no access token id so check the sha256
|
||||
checkSHA256();
|
||||
} else {
|
||||
check();
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
function renderTitle() {
|
||||
if (isValid) {
|
||||
return t('accessGranted');
|
||||
} else {
|
||||
return t('accessUrlInvalid');
|
||||
}
|
||||
}
|
||||
|
||||
function renderContent() {
|
||||
if (isValid) {
|
||||
return (
|
||||
<div>
|
||||
{t('accessGrantedDescription')}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
{t('accessUrlInvalidDescription')}
|
||||
<div className="text-center mt-4">
|
||||
<Button>
|
||||
<Link href="/">{t('goHome')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return loading ? (
|
||||
<div></div>
|
||||
) : (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-2xl font-bold">
|
||||
{renderTitle()}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>{renderContent()}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
102
src/components/AccessTokenUsage.tsx
Normal file
102
src/components/AccessTokenUsage.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Copy, Info, InfoIcon } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface AccessTokenSectionProps {
|
||||
token: string;
|
||||
tokenId: string;
|
||||
resourceUrl: string;
|
||||
}
|
||||
|
||||
export default function AccessTokenSection({
|
||||
token,
|
||||
tokenId,
|
||||
resourceUrl
|
||||
}: AccessTokenSectionProps) {
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const copyToClipboard = (text: string, type: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(type);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start space-x-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('shareTokenDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="token" className="w-full mt-4">
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="token">{t('accessToken')}</TabsTrigger>
|
||||
<TabsTrigger value="usage">{t('usageExamples')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="token" className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<div className="font-bold">{t('tokenId')}</div>
|
||||
<CopyToClipboard text={tokenId} isLink={false} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="font-bold">{t('token')}</div>
|
||||
<CopyToClipboard text={token} isLink={false} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="usage" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">{t('requestHeades')}</h3>
|
||||
<CopyTextBox
|
||||
text={`${env.server.resourceAccessTokenHeadersId}: ${tokenId}
|
||||
${env.server.resourceAccessTokenHeadersToken}: ${token}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">{t('queryParameter')}</h3>
|
||||
<CopyTextBox
|
||||
text={`${resourceUrl}?${env.server.resourceAccessTokenParam}=${tokenId}.${token}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t('importantNote')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('shareImportantDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="text-sm text-muted-foreground mt-4">
|
||||
{t('shareTokenSecurety')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
src/components/AdminIdpDataTable.tsx
Normal file
34
src/components/AdminIdpDataTable.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
export function IdpDataTable<TData, TValue>({
|
||||
columns,
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="idp-table"
|
||||
title={t('idp')}
|
||||
searchPlaceholder={t('idpSearch')}
|
||||
searchColumn="name"
|
||||
addButtonText={t('idpAdd')}
|
||||
onAdd={() => {
|
||||
router.push("/admin/idp/create");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
208
src/components/AdminIdpTable.tsx
Normal file
208
src/components/AdminIdpTable.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { IdpDataTable } from "@app/components/AdminIdpDataTable";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type IdpRow = {
|
||||
idpId: number;
|
||||
name: string;
|
||||
type: string;
|
||||
orgCount: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
idps: IdpRow[];
|
||||
};
|
||||
|
||||
export default function IdpTable({ idps }: Props) {
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedIdp, setSelectedIdp] = useState<IdpRow | null>(null);
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const deleteIdp = async (idpId: number) => {
|
||||
try {
|
||||
await api.delete(`/idp/${idpId}`);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("idpDeletedDescription")
|
||||
});
|
||||
setIsDeleteModalOpen(false);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeDisplay = (type: string) => {
|
||||
switch (type) {
|
||||
case "oidc":
|
||||
return "OAuth2/OIDC";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<IdpRow>[] = [
|
||||
{
|
||||
accessorKey: "idpId",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
ID
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("type")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const type = row.original.type;
|
||||
return (
|
||||
<Badge variant="secondary">{getTypeDisplay(type)}</Badge>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const siteRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={`/admin/idp/${siteRow.idpId}/general`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{t("viewSettings")}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedIdp(siteRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link href={`/admin/idp/${siteRow.idpId}/general`}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
className="ml-2"
|
||||
size="sm"
|
||||
>
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedIdp && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedIdp(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
{t("idpQuestionRemove", {
|
||||
name: selectedIdp.name
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
<b>{t("idpMessageRemove")}</b>
|
||||
</p>
|
||||
<p>{t("idpMessageConfirm")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("idpConfirmDelete")}
|
||||
onConfirm={async () => deleteIdp(selectedIdp.idpId)}
|
||||
string={selectedIdp.name}
|
||||
title={t("idpDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IdpDataTable columns={columns} data={idps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
src/components/AdminUsersDataTable.tsx
Normal file
31
src/components/AdminUsersDataTable.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
export function UsersDataTable<TData, TValue>({
|
||||
columns,
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="userServer-table"
|
||||
title={t('userServer')}
|
||||
searchPlaceholder={t('userSearch')}
|
||||
searchColumn="email"
|
||||
/>
|
||||
);
|
||||
}
|
||||
269
src/components/AdminUsersTable.tsx
Normal file
269
src/components/AdminUsersTable.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { UsersDataTable } from "@app/components/AdminUsersDataTable";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
|
||||
export type GlobalUserRow = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
username: string;
|
||||
email: string | null;
|
||||
type: string;
|
||||
idpId: number | null;
|
||||
idpName: string;
|
||||
dateCreated: string;
|
||||
twoFactorEnabled: boolean | null;
|
||||
twoFactorSetupRequested: boolean | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
users: GlobalUserRow[];
|
||||
};
|
||||
|
||||
export default function UsersTable({ users }: Props) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<GlobalUserRow | null>(null);
|
||||
const [rows, setRows] = useState<GlobalUserRow[]>(users);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const deleteUser = (id: string) => {
|
||||
api.delete(`/user/${id}`)
|
||||
.catch((e) => {
|
||||
console.error(t("userErrorDelete"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("userErrorDelete"),
|
||||
description: formatAxiosError(e, t("userErrorDelete"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
|
||||
const newRows = rows.filter((row) => row.id !== id);
|
||||
|
||||
setRows(newRows);
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnDef<GlobalUserRow>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
ID
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "username",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("username")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("email")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "idpName",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("identityProvider")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "twoFactorEnabled",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("twoFactor")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const userRow = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span>
|
||||
{userRow.twoFactorEnabled ||
|
||||
userRow.twoFactorSetupRequested ? (
|
||||
<span className="text-green-500">
|
||||
{t("enabled")}
|
||||
</span>
|
||||
) : (
|
||||
<span>{t("disabled")}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Open menu
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelected(r);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
router.push(`/admin/users/${r.id}`);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selected && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelected(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
{t("userQuestionRemove", {
|
||||
selectedUser:
|
||||
selected?.email ||
|
||||
selected?.name ||
|
||||
selected?.username
|
||||
})}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>{t("userMessageRemove")}</b>
|
||||
</p>
|
||||
|
||||
<p>{t("userMessageConfirm")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("userDeleteConfirm")}
|
||||
onConfirm={async () => deleteUser(selected!.id)}
|
||||
string={
|
||||
selected.email || selected.name || selected.username
|
||||
}
|
||||
title={t("userDeleteServer")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UsersDataTable columns={columns} data={rows} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
src/components/ApiKeysDataTable.tsx
Normal file
58
src/components/ApiKeysDataTable.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getPaginationRowModel,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
addApiKey?: () => void;
|
||||
}
|
||||
|
||||
export function ApiKeysDataTable<TData, TValue>({
|
||||
addApiKey,
|
||||
columns,
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="apiKeys-table"
|
||||
title={t('apiKeys')}
|
||||
searchPlaceholder={t('searchApiKeys')}
|
||||
searchColumn="name"
|
||||
onAdd={addApiKey}
|
||||
addButtonText={t('apiKeysAdd')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
192
src/components/ApiKeysTable.tsx
Normal file
192
src/components/ApiKeysTable.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import moment from "moment";
|
||||
import { ApiKeysDataTable } from "@app/components/ApiKeysDataTable";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type ApiKeyRow = {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type ApiKeyTableProps = {
|
||||
apiKeys: ApiKeyRow[];
|
||||
};
|
||||
|
||||
export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<ApiKeyRow | null>(null);
|
||||
const [rows, setRows] = useState<ApiKeyRow[]>(apiKeys);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const deleteSite = (apiKeyId: string) => {
|
||||
api.delete(`/api-key/${apiKeyId}`)
|
||||
.catch((e) => {
|
||||
console.error(t("apiKeysErrorDelete"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("apiKeysErrorDelete"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("apiKeysErrorDeleteMessage")
|
||||
)
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
|
||||
const newRows = rows.filter((row) => row.id !== apiKeyId);
|
||||
|
||||
setRows(newRows);
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnDef<ApiKeyRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "key",
|
||||
header: t("key"),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return <span className="font-mono">{r.key}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: t("createdAt"),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return <span>{moment(r.createdAt).format("lll")} </span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelected(r);
|
||||
}}
|
||||
>
|
||||
<span>{t("viewSettings")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelected(r);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex items-center justify-end">
|
||||
<Link href={`/admin/api-keys/${r.id}`}>
|
||||
<Button variant={"secondary"} className="ml-2" size="sm">
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selected && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelected(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
{t("apiKeysQuestionRemove", {
|
||||
selectedApiKey:
|
||||
selected?.name || selected?.id
|
||||
})}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>{t("apiKeysMessageRemove")}</b>
|
||||
</p>
|
||||
|
||||
<p>{t("apiKeysMessageConfirm")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("apiKeysDeleteConfirm")}
|
||||
onConfirm={async () => deleteSite(selected!.id)}
|
||||
string={selected.name}
|
||||
title={t("apiKeysDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ApiKeysDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
addApiKey={() => {
|
||||
router.push(`/admin/api-keys/create`);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
100
src/components/AutoLoginHandler.tsx
Normal file
100
src/components/AutoLoginHandler.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardDescription
|
||||
} from "@app/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type AutoLoginHandlerProps = {
|
||||
resourceId: number;
|
||||
skipToIdpId: number;
|
||||
redirectUrl: string;
|
||||
};
|
||||
|
||||
export default function AutoLoginHandler({
|
||||
resourceId,
|
||||
skipToIdpId,
|
||||
redirectUrl
|
||||
}: AutoLoginHandlerProps) {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function initiateAutoLogin() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<GenerateOidcUrlResponse>
|
||||
>(`/auth/idp/${skipToIdpId}/oidc/generate-url`, {
|
||||
redirectUrl
|
||||
});
|
||||
|
||||
if (res.data.data.redirectUrl) {
|
||||
// Redirect to the IDP for authentication
|
||||
window.location.href = res.data.data.redirectUrl;
|
||||
} else {
|
||||
setError(t("autoLoginErrorNoRedirectUrl"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to generate OIDC URL:", e);
|
||||
setError(formatAxiosError(e, t("autoLoginErrorGeneratingUrl")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
initiateAutoLogin();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("autoLoginTitle")}</CardTitle>
|
||||
<CardDescription>{t("autoLoginDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center space-y-4">
|
||||
{loading && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>{t("autoLoginProcessing")}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && (
|
||||
<div className="flex items-center space-x-2 text-green-600">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<span>{t("autoLoginRedirecting")}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="w-full">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<AlertDescription className="flex flex-col space-y-2">
|
||||
<span>{t("autoLoginError")}</span>
|
||||
<span className="text-xs">{error}</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/components/ClientInfoCard.tsx
Normal file
54
src/components/ClientInfoCard.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useClientContext } from "@app/hooks/useClientContext";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type ClientInfoCardProps = {};
|
||||
|
||||
export default function SiteInfoCard({}: ClientInfoCardProps) {
|
||||
const { client, updateClient } = useClientContext();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">{t("clientInformation")}</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<InfoSections cols={2}>
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{client.online ? (
|
||||
<div className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>{t("online")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<span>{t("offline")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("address")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{client.subnet.split("/")[0]}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
31
src/components/ClientsDataTable.tsx
Normal file
31
src/components/ClientsDataTable.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
addClient?: () => void;
|
||||
}
|
||||
|
||||
export function ClientsDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
addClient
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="clients-table"
|
||||
title="Clients"
|
||||
searchPlaceholder="Search clients..."
|
||||
searchColumn="name"
|
||||
onAdd={addClient}
|
||||
addButtonText="Add Client"
|
||||
/>
|
||||
);
|
||||
}
|
||||
298
src/components/ClientsTable.tsx
Normal file
298
src/components/ClientsTable.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { ClientsDataTable } from "@app/components/ClientsDataTable";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
MoreHorizontal,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
export type ClientRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
subnet: string;
|
||||
// siteIds: string;
|
||||
mbIn: string;
|
||||
mbOut: string;
|
||||
orgId: string;
|
||||
online: boolean;
|
||||
};
|
||||
|
||||
type ClientTableProps = {
|
||||
clients: ClientRow[];
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export default function ClientsTable({ clients, orgId }: ClientTableProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
|
||||
null
|
||||
);
|
||||
const [rows, setRows] = useState<ClientRow[]>(clients);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const deleteClient = (clientId: number) => {
|
||||
api.delete(`/client/${clientId}`)
|
||||
.catch((e) => {
|
||||
console.error("Error deleting client", e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error deleting client",
|
||||
description: formatAxiosError(e, "Error deleting client")
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
|
||||
const newRows = rows.filter((row) => row.id !== clientId);
|
||||
|
||||
setRows(newRows);
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnDef<ClientRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
// {
|
||||
// accessorKey: "siteName",
|
||||
// header: ({ column }) => {
|
||||
// return (
|
||||
// <Button
|
||||
// variant="ghost"
|
||||
// onClick={() =>
|
||||
// column.toggleSorting(column.getIsSorted() === "asc")
|
||||
// }
|
||||
// >
|
||||
// Site
|
||||
// <ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
// </Button>
|
||||
// );
|
||||
// },
|
||||
// cell: ({ row }) => {
|
||||
// const r = row.original;
|
||||
// return (
|
||||
// <Link href={`/${r.orgId}/settings/sites/${r.siteId}`}>
|
||||
// <Button variant="outline">
|
||||
// {r.siteName}
|
||||
// <ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
// </Button>
|
||||
// </Link>
|
||||
// );
|
||||
// }
|
||||
// },
|
||||
{
|
||||
accessorKey: "online",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Connectivity
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
if (originalRow.online) {
|
||||
return (
|
||||
<span className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>Connected</span>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<span>Disconnected</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "mbIn",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Data In
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "mbOut",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Data Out
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "subnet",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Address
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const clientRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* <Link */}
|
||||
{/* className="block w-full" */}
|
||||
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
|
||||
{/* > */}
|
||||
{/* <DropdownMenuItem> */}
|
||||
{/* View settings */}
|
||||
{/* </DropdownMenuItem> */}
|
||||
{/* </Link> */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedClient(clientRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
|
||||
>
|
||||
<Button variant={"secondary"} className="ml-2">
|
||||
Edit
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedClient && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedClient(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to remove the client{" "}
|
||||
<b>
|
||||
{selectedClient?.name || selectedClient?.id}
|
||||
</b>{" "}
|
||||
from the site and organization?
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>
|
||||
Once removed, the client will no longer be
|
||||
able to connect to the site.{" "}
|
||||
</b>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the name of the client
|
||||
below.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete Client"
|
||||
onConfirm={async () => deleteClient(selectedClient!.id)}
|
||||
string={selectedClient.name}
|
||||
title="Delete Client"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ClientsDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
addClient={() => {
|
||||
router.push(`/${orgId}/settings/clients/create`);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
607
src/components/CreateDomainForm.tsx
Normal file
607
src/components/CreateDomainForm.tsx
Normal file
@@ -0,0 +1,607 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { CreateDomainResponse } from "@server/routers/domain/createOrgDomain";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon, AlertTriangle, Globe } from "lucide-react";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { build } from "@server/build";
|
||||
import { toASCII, toUnicode } from 'punycode';
|
||||
|
||||
|
||||
// Helper functions for Unicode domain handling
|
||||
function toPunycode(domain: string): string {
|
||||
try {
|
||||
const parts = toASCII(domain);
|
||||
return parts;
|
||||
} catch (error) {
|
||||
return domain.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function fromPunycode(domain: string): string {
|
||||
try {
|
||||
const parts = toUnicode(domain);
|
||||
return parts;
|
||||
} catch (error) {
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
|
||||
function isValidDomainFormat(domain: string): boolean {
|
||||
const unicodeRegex = /^(?!:\/\/)([^\s.]+\.)*[^\s.]+$/;
|
||||
|
||||
if (!unicodeRegex.test(domain)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parts = domain.split('.');
|
||||
for (const part of parts) {
|
||||
if (part.length === 0 || part.startsWith('-') || part.endsWith('-')) {
|
||||
return false;
|
||||
}
|
||||
if (part.length > 63) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (domain.length > 253) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
baseDomain: z
|
||||
.string()
|
||||
.min(1, "Domain is required")
|
||||
.refine((val) => isValidDomainFormat(val), "Invalid domain format")
|
||||
.transform((val) => toPunycode(val)),
|
||||
type: z.enum(["ns", "cname", "wildcard"])
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
type CreateDomainFormProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onCreated?: (domain: CreateDomainResponse) => void;
|
||||
};
|
||||
|
||||
export default function CreateDomainForm({
|
||||
open,
|
||||
setOpen,
|
||||
onCreated
|
||||
}: CreateDomainFormProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createdDomain, setCreatedDomain] =
|
||||
useState<CreateDomainResponse | null>(null);
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
const { toast } = useToast();
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
baseDomain: "",
|
||||
type: build == "oss" ? "wildcard" : "ns"
|
||||
}
|
||||
});
|
||||
|
||||
function reset() {
|
||||
form.reset();
|
||||
setLoading(false);
|
||||
setCreatedDomain(null);
|
||||
}
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.put<AxiosResponse<CreateDomainResponse>>(
|
||||
`/org/${org.org.orgId}/domain`,
|
||||
values
|
||||
);
|
||||
const domainData = response.data.data;
|
||||
setCreatedDomain(domainData);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("domainCreatedDescription")
|
||||
});
|
||||
onCreated?.(domainData);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const baseDomain = form.watch("baseDomain");
|
||||
const domainInputValue = form.watch("baseDomain") || "";
|
||||
|
||||
const punycodePreview = useMemo(() => {
|
||||
if (!domainInputValue) return "";
|
||||
const punycode = toPunycode(domainInputValue);
|
||||
return punycode !== domainInputValue.toLowerCase() ? punycode : "";
|
||||
}, [domainInputValue]);
|
||||
|
||||
let domainOptions: any = [];
|
||||
if (build == "enterprise" || build == "saas") {
|
||||
domainOptions = [
|
||||
{
|
||||
id: "ns",
|
||||
title: t("selectDomainTypeNsName"),
|
||||
description: t("selectDomainTypeNsDescription")
|
||||
},
|
||||
{
|
||||
id: "cname",
|
||||
title: t("selectDomainTypeCnameName"),
|
||||
description: t("selectDomainTypeCnameDescription")
|
||||
}
|
||||
];
|
||||
} else if (build == "oss") {
|
||||
domainOptions = [
|
||||
{
|
||||
id: "wildcard",
|
||||
title: t("selectDomainTypeWildcardName"),
|
||||
description: t("selectDomainTypeWildcardDescription")
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("domainAdd")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("domainAddDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
{!createdDomain ? (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="create-domain-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<StrategySelect
|
||||
options={domainOptions}
|
||||
defaultValue={field.value}
|
||||
onChange={field.onChange}
|
||||
cols={1}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="baseDomain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("domain")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="example.com, café.com, 日本.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
{punycodePreview && (
|
||||
<FormDescription className="flex items-center gap-2 text-xs">
|
||||
<Alert>
|
||||
<Globe className="h-4 w-4" />
|
||||
<AlertTitle>{t("internationaldomaindetected")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div className="mt-2 space-y-1">
|
||||
<p>{t("willbestoredas")} <code className="font-mono px-1 py-0.5 rounded">{punycodePreview}</code></p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<Alert variant="default">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("createDomainAddDnsRecords")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("createDomainAddDnsRecordsDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4">
|
||||
{createdDomain.nsRecords &&
|
||||
createdDomain.nsRecords.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">
|
||||
{t("createDomainNsRecords")}
|
||||
</h3>
|
||||
<InfoSections cols={1}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("createDomainRecord")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"createDomainType"
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
NS
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"createDomainName"
|
||||
)}
|
||||
</span>
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-mono block">
|
||||
{fromPunycode(baseDomain)}
|
||||
</span>
|
||||
{fromPunycode(baseDomain) !== baseDomain && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
({baseDomain})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"createDomainValue"
|
||||
)}
|
||||
</span>
|
||||
{createdDomain.nsRecords.map(
|
||||
(
|
||||
nsRecord,
|
||||
index
|
||||
) => (
|
||||
<div
|
||||
className="flex justify-between items-center"
|
||||
key={index}
|
||||
>
|
||||
<CopyToClipboard
|
||||
text={
|
||||
nsRecord
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createdDomain.cnameRecords &&
|
||||
createdDomain.cnameRecords.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">
|
||||
{t("createDomainCnameRecords")}
|
||||
</h3>
|
||||
<InfoSections cols={1}>
|
||||
{createdDomain.cnameRecords.map(
|
||||
(cnameRecord, index) => (
|
||||
<InfoSection
|
||||
key={index}
|
||||
>
|
||||
<InfoSectionTitle>
|
||||
{t(
|
||||
"createDomainRecordNumber",
|
||||
{
|
||||
number:
|
||||
index +
|
||||
1
|
||||
}
|
||||
)}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"createDomainType"
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
CNAME
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"createDomainName"
|
||||
)}
|
||||
</span>
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-mono block">
|
||||
{fromPunycode(cnameRecord.baseDomain)}
|
||||
</span>
|
||||
{fromPunycode(cnameRecord.baseDomain) !== cnameRecord.baseDomain && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
({cnameRecord.baseDomain})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"createDomainValue"
|
||||
)}
|
||||
</span>
|
||||
<CopyToClipboard
|
||||
text={
|
||||
cnameRecord.value
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)
|
||||
)}
|
||||
</InfoSections>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createdDomain.aRecords &&
|
||||
createdDomain.aRecords.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">
|
||||
{t("createDomainARecords")}
|
||||
</h3>
|
||||
<InfoSections cols={1}>
|
||||
{createdDomain.aRecords.map(
|
||||
(aRecord, index) => (
|
||||
<InfoSection
|
||||
key={index}
|
||||
>
|
||||
<InfoSectionTitle>
|
||||
{t(
|
||||
"createDomainRecordNumber",
|
||||
{
|
||||
number:
|
||||
index +
|
||||
1
|
||||
}
|
||||
)}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"createDomainType"
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
A
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"createDomainName"
|
||||
)}
|
||||
</span>
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-mono block">
|
||||
{fromPunycode(aRecord.baseDomain)}
|
||||
</span>
|
||||
{fromPunycode(aRecord.baseDomain) !== aRecord.baseDomain && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
({aRecord.baseDomain})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"createDomainValue"
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
{
|
||||
aRecord.value
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)
|
||||
)}
|
||||
</InfoSections>
|
||||
</div>
|
||||
)}
|
||||
{createdDomain.txtRecords &&
|
||||
createdDomain.txtRecords.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">
|
||||
{t("createDomainTxtRecords")}
|
||||
</h3>
|
||||
<InfoSections cols={1}>
|
||||
{createdDomain.txtRecords.map(
|
||||
(txtRecord, index) => (
|
||||
<InfoSection
|
||||
key={index}
|
||||
>
|
||||
<InfoSectionTitle>
|
||||
{t(
|
||||
"createDomainRecordNumber",
|
||||
{
|
||||
number:
|
||||
index +
|
||||
1
|
||||
}
|
||||
)}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"createDomainType"
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
TXT
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"createDomainName"
|
||||
)}
|
||||
</span>
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-mono block">
|
||||
{fromPunycode(txtRecord.baseDomain)}
|
||||
</span>
|
||||
{fromPunycode(txtRecord.baseDomain) !== txtRecord.baseDomain && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
({txtRecord.baseDomain})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"createDomainValue"
|
||||
)}
|
||||
</span>
|
||||
<CopyToClipboard
|
||||
text={
|
||||
txtRecord.value
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)
|
||||
)}
|
||||
</InfoSections>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{build == "saas" ||
|
||||
(build == "enterprise" && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("createDomainSaveTheseRecords")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"createDomainSaveTheseRecordsDescription"
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
|
||||
<Alert variant="info">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("createDomainDnsPropagation")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("createDomainDnsPropagationDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
{!createdDomain && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-domain-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("domainCreate")}
|
||||
</Button>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
178
src/components/CreateRoleForm.tsx
Normal file
178
src/components/CreateRoleForm.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type CreateRoleFormProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
afterCreate?: (res: CreateRoleResponse) => Promise<void>;
|
||||
};
|
||||
|
||||
export default function CreateRoleForm({
|
||||
open,
|
||||
setOpen,
|
||||
afterCreate
|
||||
}: CreateRoleFormProps) {
|
||||
const { org } = useOrgContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string({ message: t('nameRequired') }).max(32),
|
||||
description: z.string().max(255).optional()
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: ""
|
||||
}
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setLoading(true);
|
||||
|
||||
const res = await api
|
||||
.put<AxiosResponse<CreateRoleResponse>>(
|
||||
`/org/${org?.org.orgId}/role`,
|
||||
{
|
||||
name: values.name,
|
||||
description: values.description
|
||||
} as CreateRoleBody
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('accessRoleErrorCreate'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('accessRoleErrorCreateDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 201) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t('accessRoleCreated'),
|
||||
description: t('accessRoleCreatedDescription')
|
||||
});
|
||||
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
if (afterCreate) {
|
||||
afterCreate(res.data.data);
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
setLoading(false);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t('accessRoleCreate')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('accessRoleCreateDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="create-role-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('accessRoleName')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-role-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('accessRoleCreateSubmit')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
</>
|
||||
);
|
||||
}
|
||||
549
src/components/CreateShareLinkForm.tsx
Normal file
549
src/components/CreateShareLinkForm.tsx
Normal file
@@ -0,0 +1,549 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { ListResourcesResponse } from "@server/routers/resource";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
|
||||
import { constructShareLink } from "@app/lib/shareLinks";
|
||||
import { ShareLinkRow } from "@app/components/ShareLinksTable";
|
||||
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger
|
||||
} from "@app/components/ui/collapsible";
|
||||
import AccessTokenSection from "@app/components/AccessTokenUsage";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toUnicode } from 'punycode';
|
||||
|
||||
type FormProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onCreated?: (result: ShareLinkRow) => void;
|
||||
};
|
||||
|
||||
export default function CreateShareLinkForm({
|
||||
open,
|
||||
setOpen,
|
||||
onCreated
|
||||
}: FormProps) {
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [link, setLink] = useState<string | null>(null);
|
||||
const [accessTokenId, setAccessTokenId] = useState<string | null>(null);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [neverExpire, setNeverExpire] = useState(false);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
const [resources, setResources] = useState<
|
||||
{
|
||||
resourceId: number;
|
||||
name: string;
|
||||
resourceUrl: string;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const formSchema = z.object({
|
||||
resourceId: z.number({ message: t('shareErrorSelectResource') }),
|
||||
resourceName: z.string(),
|
||||
resourceUrl: z.string(),
|
||||
timeUnit: z.string(),
|
||||
timeValue: z.coerce.number().int().positive().min(1),
|
||||
title: z.string().optional()
|
||||
});
|
||||
|
||||
const timeUnits = [
|
||||
{ unit: "minutes", name: t('minutes') },
|
||||
{ unit: "hours", name: t('hours') },
|
||||
{ unit: "days", name: t('days') },
|
||||
{ unit: "weeks", name: t('weeks') },
|
||||
{ unit: "months", name: t('months') },
|
||||
{ unit: "years", name: t('years') }
|
||||
];
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
timeUnit: "days",
|
||||
timeValue: 30,
|
||||
title: ""
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
async function fetchResources() {
|
||||
const res = await api
|
||||
.get<
|
||||
AxiosResponse<ListResourcesResponse>
|
||||
>(`/org/${org?.org.orgId}/resources`)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('shareErrorFetchResource'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('shareErrorFetchResourceDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res?.status === 200) {
|
||||
setResources(
|
||||
res.data.data.resources
|
||||
.filter((r) => {
|
||||
return r.http;
|
||||
})
|
||||
.map((r) => ({
|
||||
resourceId: r.resourceId,
|
||||
name: r.name,
|
||||
resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fetchResources();
|
||||
}, [open]);
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setLoading(true);
|
||||
|
||||
// convert time to seconds
|
||||
let timeInSeconds = values.timeValue;
|
||||
switch (values.timeUnit) {
|
||||
case "minutes":
|
||||
timeInSeconds *= 60;
|
||||
break;
|
||||
case "hours":
|
||||
timeInSeconds *= 60 * 60;
|
||||
break;
|
||||
case "days":
|
||||
timeInSeconds *= 60 * 60 * 24;
|
||||
break;
|
||||
case "weeks":
|
||||
timeInSeconds *= 60 * 60 * 24 * 7;
|
||||
break;
|
||||
case "months":
|
||||
timeInSeconds *= 60 * 60 * 24 * 30;
|
||||
break;
|
||||
case "years":
|
||||
timeInSeconds *= 60 * 60 * 24 * 365;
|
||||
break;
|
||||
}
|
||||
|
||||
const res = await api
|
||||
.post<AxiosResponse<GenerateAccessTokenResponse>>(
|
||||
`/resource/${values.resourceId}/access-token`,
|
||||
{
|
||||
validForSeconds: neverExpire ? undefined : timeInSeconds,
|
||||
title:
|
||||
values.title ||
|
||||
t('shareLink', {resource: (values.resourceName || "Resource" + values.resourceId)})
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('shareErrorCreate'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('shareErrorCreateDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.data.data.accessTokenId) {
|
||||
const token = res.data.data;
|
||||
const link = constructShareLink(token.accessToken);
|
||||
setLink(link);
|
||||
|
||||
setAccessToken(token.accessToken);
|
||||
setAccessTokenId(token.accessTokenId);
|
||||
|
||||
const resource = resources.find(
|
||||
(r) => r.resourceId === values.resourceId
|
||||
);
|
||||
|
||||
onCreated?.({
|
||||
accessTokenId: token.accessTokenId,
|
||||
resourceId: token.resourceId,
|
||||
resourceName: values.resourceName,
|
||||
title: token.title,
|
||||
createdAt: token.createdAt,
|
||||
expiresAt: token.expiresAt
|
||||
});
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function getSelectedResourceName(id: number) {
|
||||
const resource = resources.find((r) => r.resourceId === id);
|
||||
return `${resource?.name}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
setLink(null);
|
||||
setLoading(false);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t('shareCreate')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('shareCreateDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-4">
|
||||
{!link && (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="share-link-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="resourceId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>
|
||||
{t('resource')}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between",
|
||||
!field.value &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? getSelectedResourceName(
|
||||
field.value
|
||||
)
|
||||
: t('resourceSelect')}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={t('resourceSearch')} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t('resourcesNotFound')}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{resources.map(
|
||||
(
|
||||
r
|
||||
) => (
|
||||
<CommandItem
|
||||
value={`${r.name}:${r.resourceId}`}
|
||||
key={
|
||||
r.resourceId
|
||||
}
|
||||
onSelect={() => {
|
||||
form.setValue(
|
||||
"resourceId",
|
||||
r.resourceId
|
||||
);
|
||||
form.setValue(
|
||||
"resourceName",
|
||||
r.name
|
||||
);
|
||||
form.setValue(
|
||||
"resourceUrl",
|
||||
r.resourceUrl
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
r.resourceId ===
|
||||
field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{`${r.name}`}
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('shareTitleOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t('expireIn')}</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timeUnit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
defaultValue={field.value.toString()}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t('selectDuration')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{timeUnits.map(
|
||||
(
|
||||
option
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
option.unit
|
||||
}
|
||||
value={
|
||||
option.unit
|
||||
}
|
||||
>
|
||||
{
|
||||
option.name
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timeValue"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
checked={neverExpire}
|
||||
onCheckedChange={(val) =>
|
||||
setNeverExpire(
|
||||
val as boolean
|
||||
)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t('neverExpire')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('shareExpireDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
{link && (
|
||||
<div className="max-w-md space-y-4">
|
||||
<p>
|
||||
{t('shareSeeOnce')}
|
||||
</p>
|
||||
<p>
|
||||
{t('shareAccessHint')}
|
||||
</p>
|
||||
|
||||
<div className="h-[250px] w-full mx-auto flex items-center justify-center">
|
||||
<QRCodeCanvas value={link} size={200} />
|
||||
</div>
|
||||
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="mx-auto">
|
||||
<CopyTextBox
|
||||
text={link}
|
||||
wrapText={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="p-0 flex items-center justify-between w-full"
|
||||
>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t('shareTokenUsage')}
|
||||
</h4>
|
||||
<div>
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
{t('toggle')}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<CollapsibleContent className="space-y-2">
|
||||
{accessTokenId && accessToken && (
|
||||
<div className="space-y-2">
|
||||
<div className="mx-auto">
|
||||
<AccessTokenSection
|
||||
tokenId={
|
||||
accessTokenId
|
||||
}
|
||||
token={accessToken}
|
||||
resourceUrl={form.getValues(
|
||||
"resourceUrl"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(onSubmit)}
|
||||
loading={loading}
|
||||
disabled={link !== null || loading}
|
||||
>
|
||||
{t('createLink')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
</>
|
||||
);
|
||||
}
|
||||
103
src/components/CustomDomainInput.tsx
Normal file
103
src/components/CustomDomainInput.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
import { toUnicode } from "punycode";
|
||||
|
||||
interface DomainOption {
|
||||
baseDomain: string;
|
||||
domainId: string;
|
||||
}
|
||||
|
||||
interface CustomDomainInputProps {
|
||||
domainOptions: DomainOption[];
|
||||
selectedDomainId?: string;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onChange?: (value: string, selectedDomainId: string) => void;
|
||||
}
|
||||
|
||||
export default function CustomDomainInput({
|
||||
domainOptions,
|
||||
selectedDomainId,
|
||||
placeholder = "Subdomain",
|
||||
value: defaultValue,
|
||||
onChange
|
||||
}: CustomDomainInputProps) {
|
||||
const [value, setValue] = React.useState(defaultValue);
|
||||
const [selectedDomain, setSelectedDomain] = React.useState<DomainOption>();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (domainOptions.length) {
|
||||
if (selectedDomainId) {
|
||||
const selectedDomainOption = domainOptions.find(
|
||||
(option) => option.domainId === selectedDomainId
|
||||
);
|
||||
setSelectedDomain(selectedDomainOption || domainOptions[0]);
|
||||
} else {
|
||||
setSelectedDomain(domainOptions[0]);
|
||||
}
|
||||
}
|
||||
}, [domainOptions]);
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!selectedDomain) {
|
||||
return;
|
||||
}
|
||||
const newValue = event.target.value;
|
||||
setValue(newValue);
|
||||
if (onChange) {
|
||||
onChange(newValue, selectedDomain.domainId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDomainChange = (domainId: string) => {
|
||||
const newSelectedDomain =
|
||||
domainOptions.find((option) => option.domainId === domainId) ||
|
||||
domainOptions[0];
|
||||
setSelectedDomain(newSelectedDomain);
|
||||
if (onChange) {
|
||||
onChange(value, newSelectedDomain.domainId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
className="w-1/2 mr-1 text-right"
|
||||
/>
|
||||
<Select
|
||||
onValueChange={handleDomainChange}
|
||||
value={selectedDomain?.domainId}
|
||||
defaultValue={selectedDomain?.domainId}
|
||||
>
|
||||
<SelectTrigger className="w-1/2 pr-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{domainOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.domainId}
|
||||
value={option.domainId}
|
||||
>
|
||||
.{toUnicode(option.baseDomain)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
src/components/DashboardLoginForm.tsx
Normal file
63
src/components/DashboardLoginForm.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import LoginForm, { LoginFormIDP } from "@app/components/LoginForm";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import BrandingLogo from "@app/components/BrandingLogo";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type DashboardLoginFormProps = {
|
||||
redirect?: string;
|
||||
idps?: LoginFormIDP[];
|
||||
};
|
||||
|
||||
export default function DashboardLoginForm({
|
||||
redirect,
|
||||
idps
|
||||
}: DashboardLoginFormProps) {
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
const t = useTranslations();
|
||||
|
||||
function getSubtitle() {
|
||||
return t("loginStart");
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="shadow-md w-full max-w-md">
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<BrandingLogo height={58} width={175} />
|
||||
</div>
|
||||
<div className="text-center space-y-1 pt-3">
|
||||
<p className="text-muted-foreground">{getSubtitle()}</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<LoginForm
|
||||
redirect={redirect}
|
||||
idps={idps}
|
||||
onLogin={() => {
|
||||
if (redirect) {
|
||||
const safe = cleanRedirect(redirect);
|
||||
router.push(safe);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
231
src/components/DeleteRoleForm.tsx
Normal file
231
src/components/DeleteRoleForm.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { ListRolesResponse } from "@server/routers/role";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { RoleRow } from "@app/components/RolesTable";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type CreateRoleFormProps = {
|
||||
open: boolean;
|
||||
roleToDelete: RoleRow;
|
||||
setOpen: (open: boolean) => void;
|
||||
afterDelete?: () => void;
|
||||
};
|
||||
|
||||
export default function DeleteRoleForm({
|
||||
open,
|
||||
roleToDelete,
|
||||
setOpen,
|
||||
afterDelete
|
||||
}: CreateRoleFormProps) {
|
||||
const { org } = useOrgContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [roles, setRoles] = useState<ListRolesResponse["roles"]>([]);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const formSchema = z.object({
|
||||
newRoleId: z.string({ message: t('accessRoleErrorNewRequired') })
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchRoles() {
|
||||
const res = await api
|
||||
.get<
|
||||
AxiosResponse<ListRolesResponse>
|
||||
>(`/org/${org?.org.orgId}/roles`)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('accessRoleErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('accessRoleErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res?.status === 200) {
|
||||
setRoles(
|
||||
res.data.data.roles.filter(
|
||||
(r) => r.roleId !== roleToDelete.roleId
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fetchRoles();
|
||||
}, []);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
newRoleId: ""
|
||||
}
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setLoading(true);
|
||||
|
||||
const res = await api
|
||||
.delete(`/role/${roleToDelete.roleId}`, {
|
||||
data: {
|
||||
roleId: values.newRoleId
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('accessRoleErrorRemove'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('accessRoleErrorRemoveDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t('accessRoleRemoved'),
|
||||
description: t('accessRoleRemovedDescription')
|
||||
});
|
||||
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
if (afterDelete) {
|
||||
afterDelete();
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
setLoading(false);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t('accessRoleRemove')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('accessRoleRemoveDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
{t('accessRoleQuestionRemove', {name: roleToDelete.name})}
|
||||
</p>
|
||||
<p>
|
||||
{t('accessRoleRequiredRemove')}
|
||||
</p>
|
||||
</div>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="remove-role-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newRoleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('role')}</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('accessRoleSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem
|
||||
key={
|
||||
role.roleId
|
||||
}
|
||||
value={role.roleId.toString()}
|
||||
>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="submit"
|
||||
form="remove-role-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('accessRoleRemoveSubmit')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
src/components/DomainsDataTable.tsx
Normal file
38
src/components/DomainsDataTable.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
onAdd?: () => void;
|
||||
onRefresh?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
}
|
||||
|
||||
export function DomainsDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
onAdd,
|
||||
onRefresh,
|
||||
isRefreshing
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="domains-table"
|
||||
title={t("domains")}
|
||||
searchPlaceholder={t("domainsSearch")}
|
||||
searchColumn="baseDomain"
|
||||
addButtonText={t("domainAdd")}
|
||||
onAdd={onAdd}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
278
src/components/DomainsTable.tsx
Normal file
278
src/components/DomainsTable.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DomainsDataTable } from "@app/components/DomainsDataTable";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowUpDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import CreateDomainForm from "@app/components/CreateDomainForm";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
|
||||
export type DomainRow = {
|
||||
domainId: string;
|
||||
baseDomain: string;
|
||||
type: string;
|
||||
verified: boolean;
|
||||
failed: boolean;
|
||||
tries: number;
|
||||
configManaged: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
domains: DomainRow[];
|
||||
};
|
||||
|
||||
export default function DomainsTable({ domains }: Props) {
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [selectedDomain, setSelectedDomain] = useState<DomainRow | null>(
|
||||
null
|
||||
);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [restartingDomains, setRestartingDomains] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const { toast } = useToast();
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const refreshData = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteDomain = async (domainId: string) => {
|
||||
try {
|
||||
await api.delete(`/org/${org.org.orgId}/domain/${domainId}`);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("domainDeletedDescription")
|
||||
});
|
||||
setIsDeleteModalOpen(false);
|
||||
refreshData();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const restartDomain = async (domainId: string) => {
|
||||
setRestartingDomains((prev) => new Set(prev).add(domainId));
|
||||
try {
|
||||
await api.post(`/org/${org.org.orgId}/domain/${domainId}/restart`);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("domainRestartedDescription", {
|
||||
fallback: "Domain verification restarted successfully"
|
||||
})
|
||||
});
|
||||
refreshData();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setRestartingDomains((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(domainId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeDisplay = (type: string) => {
|
||||
switch (type) {
|
||||
case "ns":
|
||||
return t("selectDomainTypeNsName");
|
||||
case "cname":
|
||||
return t("selectDomainTypeCnameName");
|
||||
case "wildcard":
|
||||
return t("selectDomainTypeWildcardName");
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<DomainRow>[] = [
|
||||
{
|
||||
accessorKey: "baseDomain",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("domain")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("type")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const type = row.original.type;
|
||||
return (
|
||||
<Badge variant="secondary">{getTypeDisplay(type)}</Badge>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "verified",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("status")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const { verified, failed } = row.original;
|
||||
if (verified) {
|
||||
return <Badge variant="green">{t("verified")}</Badge>;
|
||||
} else if (failed) {
|
||||
return (
|
||||
<Badge variant="destructive">
|
||||
{t("failed", { fallback: "Failed" })}
|
||||
</Badge>
|
||||
);
|
||||
} else {
|
||||
return <Badge variant="yellow">{t("pending")}</Badge>;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const domain = row.original;
|
||||
const isRestarting = restartingDomains.has(domain.domainId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{domain.failed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => restartDomain(domain.domainId)}
|
||||
disabled={isRestarting}
|
||||
>
|
||||
{isRestarting
|
||||
? t("restarting", {
|
||||
fallback: "Restarting..."
|
||||
})
|
||||
: t("restart", { fallback: "Restart" })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={domain.configManaged}
|
||||
onClick={() => {
|
||||
setSelectedDomain(domain);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedDomain && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedDomain(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
{t("domainQuestionRemove", {
|
||||
domain: selectedDomain.baseDomain
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
<b>{t("domainMessageRemove")}</b>
|
||||
</p>
|
||||
<p>{t("domainMessageConfirm")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("domainConfirmDelete")}
|
||||
onConfirm={async () =>
|
||||
deleteDomain(selectedDomain.domainId)
|
||||
}
|
||||
string={selectedDomain.baseDomain}
|
||||
title={t("domainDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateDomainForm
|
||||
open={isCreateModalOpen}
|
||||
setOpen={setIsCreateModalOpen}
|
||||
onCreated={(domain) => {
|
||||
refreshData();
|
||||
}}
|
||||
/>
|
||||
|
||||
<DomainsDataTable
|
||||
columns={columns}
|
||||
data={domains}
|
||||
onAdd={() => setIsCreateModalOpen(true)}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
429
src/components/IdpCreateWizard.tsx
Normal file
429
src/components/IdpCreateWizard.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionGrid,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon, ExternalLink } from "lucide-react";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type CreateIdpFormValues = {
|
||||
name: string;
|
||||
type: "oidc";
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
authUrl: string;
|
||||
tokenUrl: string;
|
||||
identifierPath: string;
|
||||
emailPath?: string;
|
||||
namePath?: string;
|
||||
scopes: string;
|
||||
autoProvision: boolean;
|
||||
};
|
||||
|
||||
type IdpCreateWizardProps = {
|
||||
onSubmit: (data: CreateIdpFormValues) => void | Promise<void>;
|
||||
defaultValues?: Partial<CreateIdpFormValues>;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: IdpCreateWizardProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const createIdpFormSchema = z.object({
|
||||
name: z.string().min(2, { message: t('nameMin', {len: 2}) }),
|
||||
type: z.enum(["oidc"]),
|
||||
clientId: z.string().min(1, { message: t('idpClientIdRequired') }),
|
||||
clientSecret: z.string().min(1, { message: t('idpClientSecretRequired') }),
|
||||
authUrl: z.string().url({ message: t('idpErrorAuthUrlInvalid') }),
|
||||
tokenUrl: z.string().url({ message: t('idpErrorTokenUrlInvalid') }),
|
||||
identifierPath: z
|
||||
.string()
|
||||
.min(1, { message: t('idpPathRequired') }),
|
||||
emailPath: z.string().optional(),
|
||||
namePath: z.string().optional(),
|
||||
scopes: z.string().min(1, { message: t('idpScopeRequired') }),
|
||||
autoProvision: z.boolean().default(false)
|
||||
});
|
||||
|
||||
interface ProviderTypeOption {
|
||||
id: "oidc";
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const providerTypes: ReadonlyArray<ProviderTypeOption> = [
|
||||
{
|
||||
id: "oidc",
|
||||
title: "OAuth2/OIDC",
|
||||
description: t('idpOidcDescription')
|
||||
}
|
||||
];
|
||||
|
||||
const form = useForm<CreateIdpFormValues>({
|
||||
resolver: zodResolver(createIdpFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
type: "oidc",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "",
|
||||
tokenUrl: "",
|
||||
identifierPath: "sub",
|
||||
namePath: "name",
|
||||
emailPath: "email",
|
||||
scopes: "openid profile email",
|
||||
autoProvision: false,
|
||||
...defaultValues
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (data: CreateIdpFormValues) => {
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t('idpTitle')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t('idpCreateSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
id="create-idp-form"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={loading} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('idpDisplayName')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-start mb-0">
|
||||
<SwitchInput
|
||||
id="auto-provision-toggle"
|
||||
label={t('idpAutoProvisionUsers')}
|
||||
defaultChecked={form.getValues(
|
||||
"autoProvision"
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
form.setValue(
|
||||
"autoProvision",
|
||||
checked
|
||||
);
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('idpAutoProvisionUsersDescription')}
|
||||
</span>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t('idpType')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t('idpTypeDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<StrategySelect
|
||||
options={providerTypes}
|
||||
defaultValue={form.getValues("type")}
|
||||
onChange={(value) => {
|
||||
form.setValue("type", value as "oidc");
|
||||
}}
|
||||
cols={3}
|
||||
/>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{form.watch("type") === "oidc" && (
|
||||
<SettingsSectionGrid cols={2}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t('idpOidcConfigure')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t('idpOidcConfigureDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
id="create-idp-form"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('idpClientId')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={loading} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('idpClientIdDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('idpClientSecret')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('idpClientSecretDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="authUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('idpAuthUrl')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://your-idp.com/oauth2/authorize"
|
||||
{...field}
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('idpAuthUrlDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tokenUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('idpTokenUrl')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://your-idp.com/oauth2/token"
|
||||
{...field}
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('idpTokenUrlDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t('idpOidcConfigureAlert')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('idpOidcConfigureAlertDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t('idpToken')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t('idpTokenDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
id="create-idp-form"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
>
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t('idpJmespathAbout')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('idpJmespathAboutDescription')}{" "}
|
||||
<a
|
||||
href="https://jmespath.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center"
|
||||
>
|
||||
{t('idpJmespathAboutDescriptionLink')}{" "}
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="identifierPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('idpJmespathLabel')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={loading} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('idpJmespathLabelDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('idpJmespathEmailPathOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={loading} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('idpJmespathEmailPathOptionalDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="namePath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('idpJmespathNamePathOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={loading} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('idpJmespathNamePathOptionalDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scopes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('idpOidcConfigureScopes')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={loading} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('idpOidcConfigureScopesDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</SettingsSectionGrid>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
31
src/components/InvitationsDataTable.tsx
Normal file
31
src/components/InvitationsDataTable.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
export function InvitationsDataTable<TData, TValue>({
|
||||
columns,
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="invitations-table"
|
||||
title={t('invite')}
|
||||
searchPlaceholder={t('inviteSearch')}
|
||||
searchColumn="email"
|
||||
/>
|
||||
);
|
||||
}
|
||||
191
src/components/InvitationsTable.tsx
Normal file
191
src/components/InvitationsTable.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { InvitationsDataTable } from "@app/components/InvitationsDataTable";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import RegenerateInvitationForm from "@app/components/RegenerateInvitationForm";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import moment from "moment";
|
||||
|
||||
export type InvitationRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
expiresAt: string;
|
||||
role: string;
|
||||
roleId: number;
|
||||
};
|
||||
|
||||
type InvitationsTableProps = {
|
||||
invitations: InvitationRow[];
|
||||
};
|
||||
|
||||
export default function InvitationsTable({
|
||||
invitations: i
|
||||
}: InvitationsTableProps) {
|
||||
const [invitations, setInvitations] = useState<InvitationRow[]>(i);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isRegenerateModalOpen, setIsRegenerateModalOpen] = useState(false);
|
||||
const [selectedInvitation, setSelectedInvitation] =
|
||||
useState<InvitationRow | null>(null);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const columns: ColumnDef<InvitationRow>[] = [
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: t("email")
|
||||
},
|
||||
{
|
||||
accessorKey: "expiresAt",
|
||||
header: t("expiresAt"),
|
||||
cell: ({ row }) => {
|
||||
const expiresAt = new Date(row.original.expiresAt);
|
||||
const isExpired = expiresAt < new Date();
|
||||
|
||||
return (
|
||||
<span className={isExpired ? "text-red-500" : ""}>
|
||||
{moment(expiresAt).format("lll")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
header: t("role")
|
||||
},
|
||||
{
|
||||
id: "dots",
|
||||
cell: ({ row }) => {
|
||||
const invitation = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setSelectedInvitation(invitation);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("inviteRemove")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => {
|
||||
setIsRegenerateModalOpen(true);
|
||||
setSelectedInvitation(invitation);
|
||||
}}
|
||||
>
|
||||
<span>{t("inviteRegenerate")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
async function removeInvitation() {
|
||||
if (selectedInvitation) {
|
||||
const res = await api
|
||||
.delete(
|
||||
`/org/${org?.org.orgId}/invitations/${selectedInvitation.id}`
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("inviteRemoveError"),
|
||||
description: t("inviteRemoveErrorDescription")
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t("inviteRemoved"),
|
||||
description: t("inviteRemovedDescription", {
|
||||
email: selectedInvitation.email
|
||||
})
|
||||
});
|
||||
|
||||
setInvitations((prev) =>
|
||||
prev.filter(
|
||||
(invitation) => invitation.id !== selectedInvitation.id
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
setIsDeleteModalOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedInvitation(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
{t("inviteQuestionRemove", {
|
||||
email: selectedInvitation?.email || ""
|
||||
})}
|
||||
</p>
|
||||
<p>{t("inviteMessageRemove")}</p>
|
||||
<p>{t("inviteMessageConfirm")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("inviteRemoveConfirm")}
|
||||
onConfirm={removeInvitation}
|
||||
string={selectedInvitation?.email ?? ""}
|
||||
title={t("inviteRemove")}
|
||||
/>
|
||||
<RegenerateInvitationForm
|
||||
open={isRegenerateModalOpen}
|
||||
setOpen={setIsRegenerateModalOpen}
|
||||
invitation={selectedInvitation}
|
||||
onRegenerate={(updatedInvitation) => {
|
||||
setInvitations((prev) =>
|
||||
prev.map((inv) =>
|
||||
inv.id === updatedInvitation.id
|
||||
? updatedInvitation
|
||||
: inv
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<InvitationsDataTable columns={columns} data={invitations} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
129
src/components/InviteStatusCard.tsx
Normal file
129
src/components/InviteStatusCard.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@app/components/ui/card";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { XCircle } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type InviteStatusCardProps = {
|
||||
type: "rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in";
|
||||
token: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export default function InviteStatusCard({
|
||||
type,
|
||||
token,
|
||||
email,
|
||||
}: InviteStatusCardProps) {
|
||||
const router = useRouter();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
async function goToLogin() {
|
||||
await api.post("/auth/logout", {});
|
||||
const redirectUrl = email
|
||||
? `/auth/login?redirect=/invite?token=${token}&email=${encodeURIComponent(email)}`
|
||||
: `/auth/login?redirect=/invite?token=${token}`;
|
||||
router.push(redirectUrl);
|
||||
}
|
||||
|
||||
async function goToSignup() {
|
||||
await api.post("/auth/logout", {});
|
||||
const redirectUrl = email
|
||||
? `/auth/signup?redirect=/invite?token=${token}&email=${encodeURIComponent(email)}`
|
||||
: `/auth/signup?redirect=/invite?token=${token}`;
|
||||
router.push(redirectUrl);
|
||||
}
|
||||
|
||||
function renderBody() {
|
||||
if (type === "rejected") {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-center mb-4">
|
||||
{t('inviteErrorNotValid')}
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm space-y-2">
|
||||
<li>{t('inviteErrorExpired')}</li>
|
||||
<li>{t('inviteErrorRevoked')}</li>
|
||||
<li>{t('inviteErrorTypo')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
} else if (type === "wrong_user") {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-center mb-4">
|
||||
{t('inviteErrorUser')}
|
||||
</p>
|
||||
<p className="text-center">
|
||||
{t('inviteLoginUser')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
} else if (type === "user_does_not_exist") {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-center mb-4">
|
||||
{t('inviteErrorNoUser')}
|
||||
</p>
|
||||
<p className="text-center">
|
||||
{t('inviteCreateUser')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderFooter() {
|
||||
if (type === "rejected") {
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
router.push("/");
|
||||
}}
|
||||
>
|
||||
{t('goHome')}
|
||||
</Button>
|
||||
);
|
||||
} else if (type === "wrong_user") {
|
||||
return (
|
||||
<Button onClick={goToLogin}>{t('inviteLogInOtherUser')}</Button>
|
||||
);
|
||||
} else if (type === "user_does_not_exist") {
|
||||
return <Button onClick={goToSignup}>{t('createAnAccount')}</Button>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 md:mt-32 flex items-center justify-center">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
{/* <div className="flex items-center justify-center w-20 h-20 rounded-full bg-red-100 mx-auto mb-4">
|
||||
<XCircle
|
||||
className="w-10 h-10 text-red-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div> */}
|
||||
<CardTitle className="text-center text-2xl font-bold">
|
||||
{t('inviteNotAccepted')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>{renderBody()}</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-center space-x-4">
|
||||
{renderFooter()}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
src/components/LicenseKeysDataTable.tsx
Normal file
147
src/components/LicenseKeysDataTable.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { LicenseKeyCache } from "@server/license/license";
|
||||
import { ArrowUpDown } from "lucide-react";
|
||||
import moment from "moment";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type LicenseKeysDataTableProps = {
|
||||
licenseKeys: LicenseKeyCache[];
|
||||
onDelete: (key: LicenseKeyCache) => void;
|
||||
onCreate: () => void;
|
||||
};
|
||||
|
||||
function obfuscateLicenseKey(key: string): string {
|
||||
if (key.length <= 8) return key;
|
||||
const firstPart = key.substring(0, 4);
|
||||
const lastPart = key.substring(key.length - 4);
|
||||
return `${firstPart}••••••••••••••••••••${lastPart}`;
|
||||
}
|
||||
|
||||
export function LicenseKeysDataTable({
|
||||
licenseKeys,
|
||||
onDelete,
|
||||
onCreate
|
||||
}: LicenseKeysDataTableProps) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const columns: ColumnDef<LicenseKeyCache>[] = [
|
||||
{
|
||||
accessorKey: "licenseKey",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t('licenseKey')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const licenseKey = row.original.licenseKey;
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={licenseKey}
|
||||
displayText={obfuscateLicenseKey(licenseKey)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "valid",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t('valid')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return row.original.valid ? t('yes') : t('no');
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t('type')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const type = row.original.type;
|
||||
const label =
|
||||
type === "SITES" ? t('sitesAdditional') : t('licenseHost');
|
||||
const variant = type === "SITES" ? "secondary" : "default";
|
||||
return row.original.valid ? (
|
||||
<Badge variant={variant}>{label}</Badge>
|
||||
) : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "numSites",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t('numberOfSites')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onDelete(row.original)}
|
||||
>
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={licenseKeys}
|
||||
persistPageSize="licenseKeys-table"
|
||||
title={t('licenseKeys')}
|
||||
searchPlaceholder={t('licenseKeySearch')}
|
||||
searchColumn="licenseKey"
|
||||
onAdd={onCreate}
|
||||
addButtonText={t('licenseKeyAdd')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
718
src/components/MemberResourcesPortal.tsx
Normal file
718
src/components/MemberResourcesPortal.tsx
Normal file
@@ -0,0 +1,718 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
ExternalLink,
|
||||
Globe,
|
||||
Search,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Key,
|
||||
KeyRound,
|
||||
Fingerprint,
|
||||
AtSign,
|
||||
Copy,
|
||||
InfoIcon,
|
||||
Combine
|
||||
} from "lucide-react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { GetUserResourcesResponse } from "@server/routers/resource/getUserResources";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { InfoPopup } from "@/components/ui/info-popup";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
// Update Resource type to include site information
|
||||
type Resource = {
|
||||
resourceId: number;
|
||||
name: string;
|
||||
domain: string;
|
||||
enabled: boolean;
|
||||
protected: boolean;
|
||||
protocol: string;
|
||||
// Auth method fields
|
||||
sso?: boolean;
|
||||
password?: boolean;
|
||||
pincode?: boolean;
|
||||
whitelist?: boolean;
|
||||
// Site information
|
||||
siteName?: string | null;
|
||||
};
|
||||
|
||||
type MemberResourcesPortalProps = {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
// Favicon component with fallback
|
||||
const ResourceFavicon = ({
|
||||
domain,
|
||||
enabled
|
||||
}: {
|
||||
domain: string;
|
||||
enabled: boolean;
|
||||
}) => {
|
||||
const [faviconError, setFaviconError] = useState(false);
|
||||
const [faviconLoaded, setFaviconLoaded] = useState(false);
|
||||
|
||||
// Extract domain for favicon URL
|
||||
const cleanDomain = domain.replace(/^https?:\/\//, "").split("/")[0];
|
||||
const faviconUrl = `https://www.google.com/s2/favicons?domain=${cleanDomain}&sz=32`;
|
||||
|
||||
const handleFaviconLoad = () => {
|
||||
setFaviconLoaded(true);
|
||||
setFaviconError(false);
|
||||
};
|
||||
|
||||
const handleFaviconError = () => {
|
||||
setFaviconError(true);
|
||||
setFaviconLoaded(false);
|
||||
};
|
||||
|
||||
if (faviconError || !enabled) {
|
||||
return (
|
||||
<Globe className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-4 w-4 flex-shrink-0">
|
||||
{!faviconLoaded && (
|
||||
<div className="absolute inset-0 bg-muted animate-pulse rounded-sm"></div>
|
||||
)}
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt={`${cleanDomain} favicon`}
|
||||
className={`h-4 w-4 rounded-sm transition-opacity ${faviconLoaded ? "opacity-100" : "opacity-0"}`}
|
||||
onLoad={handleFaviconLoad}
|
||||
onError={handleFaviconError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Resource Info component
|
||||
const ResourceInfo = ({ resource }: { resource: Resource }) => {
|
||||
const hasAuthMethods =
|
||||
resource.sso ||
|
||||
resource.password ||
|
||||
resource.pincode ||
|
||||
resource.whitelist;
|
||||
|
||||
const infoContent = (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Site Information */}
|
||||
{resource.siteName && (
|
||||
<div>
|
||||
<div className="text-xs font-medium mb-1.5">Site</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Combine className="h-4 w-4 text-foreground shrink-0" />
|
||||
<span className="text-sm">{resource.siteName}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Authentication Methods */}
|
||||
{hasAuthMethods && (
|
||||
<div
|
||||
className={
|
||||
resource.siteName ? "border-t border-border pt-2" : ""
|
||||
}
|
||||
>
|
||||
<div className="text-xs font-medium mb-1.5">
|
||||
Authentication Methods
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{resource.sso && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-blue-50/50 dark:bg-blue-950/50">
|
||||
<Key className="h-3 w-3 text-blue-700 dark:text-blue-300" />
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
Single Sign-On (SSO)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{resource.password && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-purple-50/50 dark:bg-purple-950/50">
|
||||
<KeyRound className="h-3 w-3 text-purple-700 dark:text-purple-300" />
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
Password Protected
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{resource.pincode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-emerald-50/50 dark:bg-emerald-950/50">
|
||||
<Fingerprint className="h-3 w-3 text-emerald-700 dark:text-emerald-300" />
|
||||
</div>
|
||||
<span className="text-sm">PIN Code</span>
|
||||
</div>
|
||||
)}
|
||||
{resource.whitelist && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-amber-50/50 dark:bg-amber-950/50">
|
||||
<AtSign className="h-3 w-3 text-amber-700 dark:text-amber-300" />
|
||||
</div>
|
||||
<span className="text-sm">Email Whitelist</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resource Status - if disabled */}
|
||||
{!resource.enabled && (
|
||||
<div
|
||||
className={`${resource.siteName || hasAuthMethods ? "border-t border-border pt-2" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
|
||||
<span className="text-sm text-destructive">
|
||||
Resource Disabled
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return <InfoPopup>{infoContent}</InfoPopup>;
|
||||
};
|
||||
|
||||
// Pagination component
|
||||
const PaginationControls = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
totalItems,
|
||||
itemsPerPage
|
||||
}: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
}) => {
|
||||
const startItem = (currentPage - 1) * itemsPerPage + 1;
|
||||
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-8">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {startItem}-{endItem} of {totalItems} resources
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
|
||||
(page) => {
|
||||
// Show first page, last page, current page, and 2 pages around current
|
||||
const showPage =
|
||||
page === 1 ||
|
||||
page === totalPages ||
|
||||
Math.abs(page - currentPage) <= 1;
|
||||
|
||||
const showEllipsis =
|
||||
(page === 2 && currentPage > 4) ||
|
||||
(page === totalPages - 1 &&
|
||||
currentPage < totalPages - 3);
|
||||
|
||||
if (!showPage && !showEllipsis) return null;
|
||||
|
||||
if (showEllipsis) {
|
||||
return (
|
||||
<span
|
||||
key={page}
|
||||
className="px-2 text-muted-foreground"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={
|
||||
currentPage === page
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page)}
|
||||
className="w-8 h-8 p-0"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="gap-1"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Loading skeleton component
|
||||
const ResourceCardSkeleton = () => (
|
||||
<Card className="rounded-lg bg-card text-card-foreground flex flex-col w-full animate-pulse">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="h-6 bg-muted rounded w-3/4"></div>
|
||||
<div className="h-5 bg-muted rounded w-16"></div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-6 pb-6 flex-1 flex flex-col justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4 bg-muted rounded"></div>
|
||||
<div className="h-4 bg-muted rounded w-1/2"></div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4 bg-muted rounded"></div>
|
||||
<div className="h-4 bg-muted rounded w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="h-8 bg-muted rounded w-full"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default function MemberResourcesPortal({
|
||||
orgId
|
||||
}: MemberResourcesPortalProps) {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const { toast } = useToast();
|
||||
|
||||
const [resources, setResources] = useState<Resource[]>([]);
|
||||
const [filteredResources, setFilteredResources] = useState<Resource[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortBy, setSortBy] = useState("name-asc");
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 12; // 3x4 grid on desktop
|
||||
|
||||
const fetchUserResources = async (isRefresh = false) => {
|
||||
try {
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const response = await api.get<GetUserResourcesResponse>(
|
||||
`/org/${orgId}/user-resources`
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
setResources(response.data.data.resources);
|
||||
setFilteredResources(response.data.data.resources);
|
||||
} else {
|
||||
setError("Failed to load resources");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching user resources:", err);
|
||||
setError(
|
||||
"Failed to load resources. Please check your connection and try again."
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserResources();
|
||||
}, [orgId, api]);
|
||||
|
||||
// Filter and sort resources
|
||||
useEffect(() => {
|
||||
const filtered = resources.filter(
|
||||
(resource) =>
|
||||
resource.name
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()) ||
|
||||
resource.domain
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Sort resources
|
||||
filtered.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case "name-asc":
|
||||
return a.name.localeCompare(b.name);
|
||||
case "name-desc":
|
||||
return b.name.localeCompare(a.name);
|
||||
case "domain-asc":
|
||||
return a.domain.localeCompare(b.domain);
|
||||
case "domain-desc":
|
||||
return b.domain.localeCompare(a.domain);
|
||||
case "status-enabled":
|
||||
// Enabled first, then protected vs unprotected
|
||||
if (a.enabled !== b.enabled) return b.enabled ? 1 : -1;
|
||||
return b.protected ? 1 : -1;
|
||||
case "status-disabled":
|
||||
// Disabled first, then unprotected vs protected
|
||||
if (a.enabled !== b.enabled) return a.enabled ? 1 : -1;
|
||||
return a.protected ? 1 : -1;
|
||||
default:
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
|
||||
setFilteredResources(filtered);
|
||||
|
||||
// Reset to first page when search/sort changes
|
||||
setCurrentPage(1);
|
||||
}, [resources, searchQuery, sortBy]);
|
||||
|
||||
// Calculate pagination
|
||||
const totalPages = Math.ceil(filteredResources.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const paginatedResources = filteredResources.slice(
|
||||
startIndex,
|
||||
startIndex + itemsPerPage
|
||||
);
|
||||
|
||||
const handleOpenResource = (resource: Resource) => {
|
||||
// Open the resource in a new tab
|
||||
window.open(resource.domain, "_blank");
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchUserResources(true);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
fetchUserResources();
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
// Scroll to top when page changes
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<SettingsSectionTitle
|
||||
title="Resources"
|
||||
description="Resources you have access to in this organization"
|
||||
/>
|
||||
|
||||
{/* Search and Sort Controls - Skeleton */}
|
||||
<div className="mb-6 flex flex-col sm:flex-row gap-4 justify-start">
|
||||
<div className="relative w-full sm:w-80">
|
||||
<div className="h-10 bg-muted rounded animate-pulse"></div>
|
||||
</div>
|
||||
<div className="w-full sm:w-36">
|
||||
<div className="h-10 bg-muted rounded animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading Skeletons */}
|
||||
<div className="grid gap-4 sm:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 auto-cols-fr">
|
||||
{Array.from({ length: 12 }).map((_, index) => (
|
||||
<ResourceCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<SettingsSectionTitle
|
||||
title="Resources"
|
||||
description="Resources you have access to in this organization"
|
||||
/>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="mb-6">
|
||||
<AlertCircle className="h-16 w-16 text-destructive/60" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-3">
|
||||
Unable to Load Resources
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-lg text-base mb-6">
|
||||
{error}
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleRetry}
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<SettingsSectionTitle
|
||||
title="Resources"
|
||||
description="Resources you have access to in this organization"
|
||||
/>
|
||||
|
||||
{/* Search and Sort Controls with Refresh */}
|
||||
<div className="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start">
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-start flex-1">
|
||||
{/* Search */}
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Input
|
||||
placeholder="Search resources..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 bg-card"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="w-full sm:w-36">
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="bg-card">
|
||||
<SelectValue placeholder="Sort by..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name-asc">
|
||||
Name A-Z
|
||||
</SelectItem>
|
||||
<SelectItem value="name-desc">
|
||||
Name Z-A
|
||||
</SelectItem>
|
||||
<SelectItem value="domain-asc">
|
||||
Domain A-Z
|
||||
</SelectItem>
|
||||
<SelectItem value="domain-desc">
|
||||
Domain Z-A
|
||||
</SelectItem>
|
||||
<SelectItem value="status-enabled">
|
||||
Enabled First
|
||||
</SelectItem>
|
||||
<SelectItem value="status-disabled">
|
||||
Disabled First
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Refresh Button */}
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={refreshing}
|
||||
className="gap-2 shrink-0"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Resources Content */}
|
||||
{filteredResources.length === 0 ? (
|
||||
/* Enhanced Empty State */
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="mb-8 p-4 rounded-full bg-muted/20 dark:bg-muted/30">
|
||||
{searchQuery ? (
|
||||
<Search className="h-12 w-12 text-muted-foreground/70" />
|
||||
) : (
|
||||
<Globe className="h-12 w-12 text-muted-foreground/70" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-foreground mb-3">
|
||||
{searchQuery
|
||||
? "No Resources Found"
|
||||
: "No Resources Available"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-lg text-base mb-6">
|
||||
{searchQuery
|
||||
? `No resources match "${searchQuery}". Try adjusting your search terms or clearing the search to see all resources.`
|
||||
: "You don't have access to any resources yet. Contact your administrator to get access to resources you need."}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{searchQuery ? (
|
||||
<Button
|
||||
onClick={() => setSearchQuery("")}
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
>
|
||||
Clear Search
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
variant="outline"
|
||||
disabled={refreshing}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh Resources
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Resources Grid */}
|
||||
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr">
|
||||
{paginatedResources.map((resource) => (
|
||||
<Card key={resource.resourceId}>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center min-w-0 flex-1 gap-3 overflow-hidden">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="min-w-0 max-w-full">
|
||||
<CardTitle className="text-lg font-bold text-foreground truncate group-hover:text-primary transition-colors">
|
||||
{resource.name}
|
||||
</CardTitle>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs break-words">
|
||||
{resource.name}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<ResourceInfo resource={resource} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<button
|
||||
onClick={() =>
|
||||
handleOpenResource(resource)
|
||||
}
|
||||
className="text-sm text-muted-foreground font-medium text-left truncate flex-1"
|
||||
disabled={!resource.enabled}
|
||||
>
|
||||
{resource.domain.replace(
|
||||
/^https?:\/\//,
|
||||
""
|
||||
)}
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
resource.domain
|
||||
);
|
||||
toast({
|
||||
title: "Copied to clipboard",
|
||||
description:
|
||||
"Resource URL has been copied to your clipboard.",
|
||||
duration: 2000
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 pt-0 mt-auto">
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleOpenResource(resource)
|
||||
}
|
||||
className="w-full h-9 transition-all group-hover:shadow-sm"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!resource.enabled}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5 mr-2" />
|
||||
Open Resource
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<PaginationControls
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
totalItems={filteredResources.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/components/OrgApiKeysDataTable.tsx
Normal file
33
src/components/OrgApiKeysDataTable.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
addApiKey?: () => void;
|
||||
}
|
||||
|
||||
export function OrgApiKeysDataTable<TData, TValue>({
|
||||
addApiKey,
|
||||
columns,
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="Org-apikeys-table"
|
||||
title={t('apiKeys')}
|
||||
searchPlaceholder={t('searchApiKeys')}
|
||||
searchColumn="name"
|
||||
onAdd={addApiKey}
|
||||
addButtonText={t('apiKeysAdd')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
199
src/components/OrgApiKeysTable.tsx
Normal file
199
src/components/OrgApiKeysTable.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { OrgApiKeysDataTable } from "@app/components/OrgApiKeysDataTable";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import moment from "moment";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type OrgApiKeyRow = {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type OrgApiKeyTableProps = {
|
||||
apiKeys: OrgApiKeyRow[];
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export default function OrgApiKeysTable({
|
||||
apiKeys,
|
||||
orgId
|
||||
}: OrgApiKeyTableProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<OrgApiKeyRow | null>(null);
|
||||
const [rows, setRows] = useState<OrgApiKeyRow[]>(apiKeys);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const deleteSite = (apiKeyId: string) => {
|
||||
api.delete(`/org/${orgId}/api-key/${apiKeyId}`)
|
||||
.catch((e) => {
|
||||
console.error(t("apiKeysErrorDelete"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("apiKeysErrorDelete"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("apiKeysErrorDeleteMessage")
|
||||
)
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
|
||||
const newRows = rows.filter((row) => row.id !== apiKeyId);
|
||||
|
||||
setRows(newRows);
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnDef<OrgApiKeyRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "key",
|
||||
header: t("key"),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return <span className="font-mono">{r.key}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: t("createdAt"),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return <span>{moment(r.createdAt).format("lll")}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelected(r);
|
||||
}}
|
||||
>
|
||||
<span>{t("viewSettings")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelected(r);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Link href={`/${orgId}/settings/api-keys/${r.id}`}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
className="ml-2"
|
||||
size="sm"
|
||||
>
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selected && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelected(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
{t("apiKeysQuestionRemove", {
|
||||
selectedApiKey:
|
||||
selected?.name || selected?.id
|
||||
})}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>{t("apiKeysMessageRemove")}</b>
|
||||
</p>
|
||||
|
||||
<p>{t("apiKeysMessageConfirm")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("apiKeysDeleteConfirm")}
|
||||
onConfirm={async () => deleteSite(selected!.id)}
|
||||
string={selected.name}
|
||||
title={t("apiKeysDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<OrgApiKeysDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
addApiKey={() => {
|
||||
router.push(`/${orgId}/settings/api-keys/create`);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
105
src/components/OrganizationLandingCard.tsx
Normal file
105
src/components/OrganizationLandingCard.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardFooter
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Users, Globe, Database, Cog, Settings, Waypoints, Combine } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface OrgStat {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
type OrganizationLandingCardProps = {
|
||||
overview: {
|
||||
orgName: string;
|
||||
stats: {
|
||||
sites: number;
|
||||
resources: number;
|
||||
users: number;
|
||||
};
|
||||
userRole: string;
|
||||
isAdmin: boolean;
|
||||
isOwner: boolean;
|
||||
orgId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function OrganizationLandingCard(
|
||||
props: OrganizationLandingCardProps
|
||||
) {
|
||||
const [orgData] = useState(props);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const orgStats: OrgStat[] = [
|
||||
{
|
||||
label: t('sites'),
|
||||
value: orgData.overview.stats.sites,
|
||||
icon: <Combine className="h-6 w-6" />
|
||||
},
|
||||
{
|
||||
label: t('resources'),
|
||||
value: orgData.overview.stats.resources,
|
||||
icon: <Waypoints className="h-6 w-6" />
|
||||
},
|
||||
{
|
||||
label: t('users'),
|
||||
value: orgData.overview.stats.users,
|
||||
icon: <Users className="h-6 w-6" />
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center text-3xl font-bold">
|
||||
{orgData.overview.orgName}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
{orgStats.map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col items-center p-4 bg-secondary rounded-lg"
|
||||
>
|
||||
{stat.icon}
|
||||
<span className="mt-2 text-2xl font-bold">
|
||||
{stat.value}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{stat.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-center text-lg">
|
||||
{t('accessRoleYour')}{" "}
|
||||
<span className="font-semibold">
|
||||
{orgData.overview.isOwner ? t('accessRoleOwner') : orgData.overview.userRole}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
{orgData.overview.isAdmin && (
|
||||
<CardFooter className="flex justify-center">
|
||||
<Link href={`/${orgData.overview.orgId}/settings`}>
|
||||
<Button size="lg" className="w-full md:w-auto">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{t('orgGeneralSettings')}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
33
src/components/PolicyDataTable.tsx
Normal file
33
src/components/PolicyDataTable.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
export function PolicyDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
onAdd
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="orgPolicies-table"
|
||||
title={t('orgPolicies')}
|
||||
searchPlaceholder={t('orgPoliciesSearch')}
|
||||
searchColumn="orgId"
|
||||
addButtonText={t('orgPoliciesAdd')}
|
||||
onAdd={onAdd}
|
||||
/>
|
||||
);
|
||||
}
|
||||
156
src/components/PolicyTable.tsx
Normal file
156
src/components/PolicyTable.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Trash2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
ArrowRight
|
||||
} from "lucide-react";
|
||||
import { PolicyDataTable } from "@app/components/PolicyDataTable";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export interface PolicyRow {
|
||||
orgId: string;
|
||||
roleMapping?: string;
|
||||
orgMapping?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
policies: PolicyRow[];
|
||||
onDelete: (orgId: string) => void;
|
||||
onAdd: () => void;
|
||||
onEdit: (policy: PolicyRow) => void;
|
||||
}
|
||||
|
||||
export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props) {
|
||||
const t = useTranslations();
|
||||
const columns: ColumnDef<PolicyRow>[] = [
|
||||
{
|
||||
id: "dots",
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">{t('openMenu')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onDelete(r.orgId);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">{t('delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "orgId",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t('orgId')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "roleMapping",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t('roleMapping')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const mapping = row.original.roleMapping;
|
||||
return mapping ? (
|
||||
<InfoPopup
|
||||
text={mapping.length > 50 ? `${mapping.substring(0, 50)}...` : mapping}
|
||||
info={mapping}
|
||||
/>
|
||||
) : (
|
||||
"--"
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "orgMapping",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t('orgMapping')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const mapping = row.original.orgMapping;
|
||||
return mapping ? (
|
||||
<InfoPopup
|
||||
text={mapping.length > 50 ? `${mapping.substring(0, 50)}...` : mapping}
|
||||
info={mapping}
|
||||
/>
|
||||
) : (
|
||||
"--"
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const policy = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
className="ml-2"
|
||||
onClick={() => onEdit(policy)}
|
||||
>
|
||||
{t('edit')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return <PolicyDataTable columns={columns} data={policies} onAdd={onAdd} />;
|
||||
}
|
||||
251
src/components/RegenerateInvitationForm.tsx
Normal file
251
src/components/RegenerateInvitationForm.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { useState, useEffect } from "react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type RegenerateInvitationFormProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
invitation: {
|
||||
id: string;
|
||||
email: string;
|
||||
roleId: number;
|
||||
role: string;
|
||||
} | null;
|
||||
onRegenerate: (updatedInvitation: {
|
||||
id: string;
|
||||
email: string;
|
||||
expiresAt: string;
|
||||
role: string;
|
||||
roleId: number;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export default function RegenerateInvitationForm({
|
||||
open,
|
||||
setOpen,
|
||||
invitation,
|
||||
onRegenerate
|
||||
}: RegenerateInvitationFormProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
const [sendEmail, setSendEmail] = useState(true);
|
||||
const [validHours, setValidHours] = useState(72);
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const validForOptions = [
|
||||
{ hours: 24, name: t('day', {count: 1}) },
|
||||
{ hours: 48, name: t('day', {count: 2}) },
|
||||
{ hours: 72, name: t('day', {count: 3}) },
|
||||
{ hours: 96, name: t('day', {count: 4}) },
|
||||
{ hours: 120, name: t('day', {count: 5}) },
|
||||
{ hours: 144, name: t('day', {count: 6}) },
|
||||
{ hours: 168, name: t('day', {count: 7}) }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSendEmail(true);
|
||||
setValidHours(72);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function handleRegenerate() {
|
||||
if (!invitation) return;
|
||||
|
||||
if (!org?.org.orgId) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('orgMissing'),
|
||||
description: t('orgMissingMessage'),
|
||||
duration: 5000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await api.post(`/org/${org.org.orgId}/create-invite`, {
|
||||
email: invitation.email,
|
||||
roleId: invitation.roleId,
|
||||
validHours,
|
||||
sendEmail,
|
||||
regenerate: true
|
||||
});
|
||||
|
||||
if (res.status === 200) {
|
||||
const link = res.data.data.inviteLink;
|
||||
setInviteLink(link);
|
||||
|
||||
if (sendEmail) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t('inviteRegenerated'),
|
||||
description: t('inviteSent', {email: invitation.email}),
|
||||
duration: 5000
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t('inviteRegenerated'),
|
||||
description: t('inviteGenerate', {email: invitation.email}),
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
|
||||
onRegenerate({
|
||||
id: invitation.id,
|
||||
email: invitation.email,
|
||||
expiresAt: res.data.data.expiresAt,
|
||||
role: invitation.role,
|
||||
roleId: invitation.roleId
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 409) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('inviteDuplicateError'),
|
||||
description: t('inviteDuplicateErrorDescription'),
|
||||
duration: 5000
|
||||
});
|
||||
} else if (error.response?.status === 429) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('inviteRateLimitError'),
|
||||
description: t('inviteRateLimitErrorDescription'),
|
||||
duration: 5000
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('inviteRegenerateError'),
|
||||
description: t('inviteRegenerateErrorDescription'),
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
setInviteLink(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t('inviteRegenerate')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('inviteRegenerateDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
{!inviteLink ? (
|
||||
<div>
|
||||
<p>
|
||||
{t('inviteQuestionRegenerate', {email: invitation?.email || ""})}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2 mt-4">
|
||||
<Checkbox
|
||||
id="send-email"
|
||||
checked={sendEmail}
|
||||
onCheckedChange={(e) =>
|
||||
setSendEmail(e as boolean)
|
||||
}
|
||||
/>
|
||||
<label htmlFor="send-email">
|
||||
{t('inviteSentEmail')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label>
|
||||
{t('inviteValidityPeriod')}
|
||||
</Label>
|
||||
<Select
|
||||
value={validHours.toString()}
|
||||
onValueChange={(value) =>
|
||||
setValidHours(parseInt(value))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t('inviteValidityPeriodSelect')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{validForOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.hours}
|
||||
value={option.hours.toString()}
|
||||
>
|
||||
{option.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 max-w-md">
|
||||
<p>
|
||||
{t('inviteRegenerateMessage')}
|
||||
</p>
|
||||
<CopyTextBox text={inviteLink} wrapText={false} />
|
||||
</div>
|
||||
)}
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
{!inviteLink ? (
|
||||
<>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t('cancel')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
onClick={handleRegenerate}
|
||||
loading={loading}
|
||||
>
|
||||
{t('inviteRegenerateButton')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
533
src/components/ResetPasswordForm.tsx
Normal file
533
src/components/ResetPasswordForm.tsx
Normal file
@@ -0,0 +1,533 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot
|
||||
} from "@/components/ui/input-otp";
|
||||
import { AxiosResponse } from "axios";
|
||||
import {
|
||||
RequestPasswordResetBody,
|
||||
RequestPasswordResetResponse,
|
||||
ResetPasswordBody,
|
||||
ResetPasswordResponse
|
||||
} from "@server/routers/auth";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "./ui/alert";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const requestSchema = z.object({
|
||||
email: z.string().email()
|
||||
});
|
||||
|
||||
export type ResetPasswordFormProps = {
|
||||
emailParam?: string;
|
||||
tokenParam?: string;
|
||||
redirect?: string;
|
||||
quickstart?: boolean;
|
||||
};
|
||||
|
||||
export default function ResetPasswordForm({
|
||||
emailParam,
|
||||
tokenParam,
|
||||
redirect,
|
||||
quickstart
|
||||
}: ResetPasswordFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
function getState() {
|
||||
if (emailParam && !tokenParam) {
|
||||
return "request";
|
||||
}
|
||||
|
||||
if (emailParam && tokenParam) {
|
||||
return "reset";
|
||||
}
|
||||
|
||||
return "request";
|
||||
}
|
||||
|
||||
const [state, setState] = useState<"request" | "reset" | "mfa">(getState());
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
email: z.string().email({ message: t('emailInvalid') }),
|
||||
token: z.string().min(8, { message: t('tokenInvalid') }),
|
||||
password: passwordSchema,
|
||||
confirmPassword: passwordSchema
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: t('passwordNotMatch')
|
||||
});
|
||||
|
||||
const mfaSchema = z.object({
|
||||
code: z.string().length(6, { message: t('pincodeInvalid') })
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: emailParam || "",
|
||||
token: tokenParam || "",
|
||||
password: "",
|
||||
confirmPassword: ""
|
||||
}
|
||||
});
|
||||
|
||||
const mfaForm = useForm<z.infer<typeof mfaSchema>>({
|
||||
resolver: zodResolver(mfaSchema),
|
||||
defaultValues: {
|
||||
code: ""
|
||||
}
|
||||
});
|
||||
|
||||
const requestForm = useForm<z.infer<typeof requestSchema>>({
|
||||
resolver: zodResolver(requestSchema),
|
||||
defaultValues: {
|
||||
email: emailParam || ""
|
||||
}
|
||||
});
|
||||
|
||||
async function onRequest(data: z.infer<typeof requestSchema>) {
|
||||
const { email } = data;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
const res = await api
|
||||
.post<AxiosResponse<RequestPasswordResetResponse>>(
|
||||
"/auth/reset-password/request",
|
||||
{
|
||||
email
|
||||
} as RequestPasswordResetBody
|
||||
)
|
||||
.catch((e) => {
|
||||
setError(formatAxiosError(e, t('errorOccurred')));
|
||||
console.error(t('passwordErrorRequestReset'), e);
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
|
||||
if (res && res.data?.data) {
|
||||
setError(null);
|
||||
setState("reset");
|
||||
setIsSubmitting(false);
|
||||
form.setValue("email", email);
|
||||
}
|
||||
}
|
||||
|
||||
async function onReset(data: any) {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const { password, email, token } = form.getValues();
|
||||
const { code } = mfaForm.getValues();
|
||||
|
||||
const res = await api
|
||||
.post<AxiosResponse<ResetPasswordResponse>>(
|
||||
"/auth/reset-password",
|
||||
{
|
||||
email,
|
||||
token,
|
||||
newPassword: password,
|
||||
code
|
||||
} as ResetPasswordBody
|
||||
)
|
||||
.catch((e) => {
|
||||
setError(formatAxiosError(e, t('errorOccurred')));
|
||||
console.error(t('passwordErrorReset'), e);
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
|
||||
console.log(res);
|
||||
|
||||
if (res) {
|
||||
setError(null);
|
||||
|
||||
if (res.data.data?.codeRequested) {
|
||||
setState("mfa");
|
||||
setIsSubmitting(false);
|
||||
mfaForm.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccessMessage(quickstart ? t('accountSetupSuccess') : t('passwordResetSuccess'));
|
||||
|
||||
// Auto-login after successful password reset
|
||||
try {
|
||||
const loginRes = await api.post("/auth/login", {
|
||||
email: form.getValues("email"),
|
||||
password: form.getValues("password")
|
||||
});
|
||||
|
||||
if (loginRes.data.data?.codeRequested) {
|
||||
if (redirect) {
|
||||
router.push(`/auth/login?redirect=${redirect}`);
|
||||
} else {
|
||||
router.push("/auth/login");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (loginRes.data.data?.emailVerificationRequired) {
|
||||
try {
|
||||
await api.post("/auth/verify-email/request");
|
||||
} catch (verificationError) {
|
||||
console.error("Failed to send verification code:", verificationError);
|
||||
}
|
||||
|
||||
if (redirect) {
|
||||
router.push(`/auth/verify-email?redirect=${redirect}`);
|
||||
} else {
|
||||
router.push("/auth/verify-email");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Login successful, redirect
|
||||
setTimeout(() => {
|
||||
if (redirect) {
|
||||
const safe = cleanRedirect(redirect);
|
||||
router.push(safe);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
}, 1500);
|
||||
|
||||
} catch (loginError) {
|
||||
// Auto-login failed, but password reset was successful
|
||||
console.error("Auto-login failed:", loginError);
|
||||
setTimeout(() => {
|
||||
if (redirect) {
|
||||
const safe = cleanRedirect(redirect);
|
||||
router.push(safe);
|
||||
} else {
|
||||
router.push("/login");
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{quickstart ? t('completeAccountSetup') : t('passwordReset')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{quickstart
|
||||
? t('completeAccountSetupDescription')
|
||||
: t('passwordResetDescription')
|
||||
}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{state === "request" && (
|
||||
<Form {...requestForm}>
|
||||
<form
|
||||
onSubmit={requestForm.handleSubmit(
|
||||
onRequest
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="form"
|
||||
>
|
||||
<FormField
|
||||
control={requestForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{quickstart
|
||||
? t('accountSetupSent')
|
||||
: t('passwordResetSent')
|
||||
}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{state === "reset" && (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onReset)}
|
||||
className="space-y-4"
|
||||
id="form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('email')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!tokenParam && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{quickstart
|
||||
? t('accountSetupCode')
|
||||
: t('passwordResetCode')
|
||||
}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{quickstart
|
||||
? t('accountSetupCodeDescription')
|
||||
: t('passwordResetCodeDescription')
|
||||
}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{quickstart
|
||||
? t('passwordCreate')
|
||||
: t('passwordNew')
|
||||
}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{quickstart
|
||||
? t('passwordCreateConfirm')
|
||||
: t('passwordNewConfirm')
|
||||
}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{state === "mfa" && (
|
||||
<Form {...mfaForm}>
|
||||
<form
|
||||
onSubmit={mfaForm.handleSubmit(onReset)}
|
||||
className="space-y-4"
|
||||
id="form"
|
||||
>
|
||||
<FormField
|
||||
control={mfaForm.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('pincodeAuth')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
{...field}
|
||||
pattern={
|
||||
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||
}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot
|
||||
index={0}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={1}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={2}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={3}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={4}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={5}
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<Alert variant="success">
|
||||
<AlertDescription>
|
||||
{successMessage}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{(state === "reset" || state === "mfa") && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="form"
|
||||
className="w-full"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{state === "reset"
|
||||
? (quickstart ? t('completeSetup') : t('passwordReset'))
|
||||
: t('pincodeSubmit2')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{state === "request" && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="form"
|
||||
className="w-full"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{quickstart
|
||||
? t('accountSetupSubmit')
|
||||
: t('passwordResetSubmit')
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{state === "mfa" && (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setState("reset");
|
||||
mfaForm.reset();
|
||||
}}
|
||||
>
|
||||
{t('passwordBack')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(state === "mfa" || state === "reset") && (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setState("request");
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
{t('backToEmail')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/ResourceAccessDenied.tsx
Normal file
34
src/components/ResourceAccessDenied.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@app/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function ResourceAccessDenied() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-2xl font-bold">
|
||||
{t('accessDenied')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{t('accessDeniedDescription')}
|
||||
<div className="text-center mt-4">
|
||||
<Button>
|
||||
<Link href="/">{t('goHome')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
662
src/components/ResourceAuthPortal.tsx
Normal file
662
src/components/ResourceAuthPortal.tsx
Normal file
@@ -0,0 +1,662 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { LockIcon, Binary, Key, User, Send, AtSign } from "lucide-react";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot
|
||||
} from "@app/components/ui/input-otp";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import LoginForm, { LoginFormIDP } from "@app/components/LoginForm";
|
||||
import {
|
||||
AuthWithPasswordResponse,
|
||||
AuthWithWhitelistResponse
|
||||
} from "@server/routers/resource";
|
||||
import ResourceAccessDenied from "@app/components/ResourceAccessDenied";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const pinSchema = z.object({
|
||||
pin: z
|
||||
.string()
|
||||
.length(6, { message: "PIN must be exactly 6 digits" })
|
||||
.regex(/^\d+$/, { message: "PIN must only contain numbers" })
|
||||
});
|
||||
|
||||
const passwordSchema = z.object({
|
||||
password: z.string().min(1, {
|
||||
message: "Password must be at least 1 character long"
|
||||
})
|
||||
});
|
||||
|
||||
const requestOtpSchema = z.object({
|
||||
email: z.string().email()
|
||||
});
|
||||
|
||||
const submitOtpSchema = z.object({
|
||||
email: z.string().email(),
|
||||
otp: z.string().min(1, {
|
||||
message: "OTP must be at least 1 character long"
|
||||
})
|
||||
});
|
||||
|
||||
type ResourceAuthPortalProps = {
|
||||
methods: {
|
||||
password: boolean;
|
||||
pincode: boolean;
|
||||
sso: boolean;
|
||||
whitelist: boolean;
|
||||
};
|
||||
resource: {
|
||||
name: string;
|
||||
id: number;
|
||||
};
|
||||
redirect: string;
|
||||
idps?: LoginFormIDP[];
|
||||
};
|
||||
|
||||
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const getNumMethods = () => {
|
||||
let colLength = 0;
|
||||
if (props.methods.pincode) colLength++;
|
||||
if (props.methods.password) colLength++;
|
||||
if (props.methods.sso) colLength++;
|
||||
if (props.methods.whitelist) colLength++;
|
||||
return colLength;
|
||||
};
|
||||
|
||||
const [numMethods, setNumMethods] = useState(getNumMethods());
|
||||
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const [pincodeError, setPincodeError] = useState<string | null>(null);
|
||||
const [whitelistError, setWhitelistError] = useState<string | null>(null);
|
||||
const [accessDenied, setAccessDenied] = useState<boolean>(false);
|
||||
const [loadingLogin, setLoadingLogin] = useState(false);
|
||||
|
||||
const [otpState, setOtpState] = useState<"idle" | "otp_sent">("idle");
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const { supporterStatus } = useSupporterStatusContext();
|
||||
|
||||
function getDefaultSelectedMethod() {
|
||||
if (props.methods.sso) {
|
||||
return "sso";
|
||||
}
|
||||
|
||||
if (props.methods.password) {
|
||||
return "password";
|
||||
}
|
||||
|
||||
if (props.methods.pincode) {
|
||||
return "pin";
|
||||
}
|
||||
|
||||
if (props.methods.whitelist) {
|
||||
return "whitelist";
|
||||
}
|
||||
}
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getDefaultSelectedMethod());
|
||||
|
||||
const pinForm = useForm<z.infer<typeof pinSchema>>({
|
||||
resolver: zodResolver(pinSchema),
|
||||
defaultValues: {
|
||||
pin: ""
|
||||
}
|
||||
});
|
||||
|
||||
const passwordForm = useForm<z.infer<typeof passwordSchema>>({
|
||||
resolver: zodResolver(passwordSchema),
|
||||
defaultValues: {
|
||||
password: ""
|
||||
}
|
||||
});
|
||||
|
||||
const requestOtpForm = useForm<z.infer<typeof requestOtpSchema>>({
|
||||
resolver: zodResolver(requestOtpSchema),
|
||||
defaultValues: {
|
||||
email: ""
|
||||
}
|
||||
});
|
||||
|
||||
const submitOtpForm = useForm<z.infer<typeof submitOtpSchema>>({
|
||||
resolver: zodResolver(submitOtpSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
otp: ""
|
||||
}
|
||||
});
|
||||
|
||||
function appendRequestToken(url: string, token: string) {
|
||||
const fullUrl = new URL(url);
|
||||
fullUrl.searchParams.append(
|
||||
env.server.resourceSessionRequestParam,
|
||||
token
|
||||
);
|
||||
return fullUrl.toString();
|
||||
}
|
||||
|
||||
const onWhitelistSubmit = (values: any) => {
|
||||
setLoadingLogin(true);
|
||||
api.post<AxiosResponse<AuthWithWhitelistResponse>>(
|
||||
`/auth/resource/${props.resource.id}/whitelist`,
|
||||
{ email: values.email, otp: values.otp }
|
||||
)
|
||||
.then((res) => {
|
||||
setWhitelistError(null);
|
||||
|
||||
if (res.data.data.otpSent) {
|
||||
setOtpState("otp_sent");
|
||||
submitOtpForm.setValue("email", values.email);
|
||||
toast({
|
||||
title: t("otpEmailSent"),
|
||||
description: t("otpEmailSentDescription")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const session = res.data.data.session;
|
||||
if (session) {
|
||||
window.location.href = appendRequestToken(
|
||||
props.redirect,
|
||||
session
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setWhitelistError(
|
||||
formatAxiosError(e, t("otpEmailErrorAuthenticate"))
|
||||
);
|
||||
})
|
||||
.then(() => setLoadingLogin(false));
|
||||
};
|
||||
|
||||
const onPinSubmit = (values: z.infer<typeof pinSchema>) => {
|
||||
setLoadingLogin(true);
|
||||
api.post<AxiosResponse<AuthWithPasswordResponse>>(
|
||||
`/auth/resource/${props.resource.id}/pincode`,
|
||||
{ pincode: values.pin }
|
||||
)
|
||||
.then((res) => {
|
||||
setPincodeError(null);
|
||||
const session = res.data.data.session;
|
||||
if (session) {
|
||||
window.location.href = appendRequestToken(
|
||||
props.redirect,
|
||||
session
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setPincodeError(
|
||||
formatAxiosError(e, t("pincodeErrorAuthenticate"))
|
||||
);
|
||||
})
|
||||
.then(() => setLoadingLogin(false));
|
||||
};
|
||||
|
||||
const onPasswordSubmit = (values: z.infer<typeof passwordSchema>) => {
|
||||
setLoadingLogin(true);
|
||||
|
||||
api.post<AxiosResponse<AuthWithPasswordResponse>>(
|
||||
`/auth/resource/${props.resource.id}/password`,
|
||||
{
|
||||
password: values.password
|
||||
}
|
||||
)
|
||||
.then((res) => {
|
||||
setPasswordError(null);
|
||||
const session = res.data.data.session;
|
||||
if (session) {
|
||||
window.location.href = appendRequestToken(
|
||||
props.redirect,
|
||||
session
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setPasswordError(
|
||||
formatAxiosError(e, t("passwordErrorAuthenticate"))
|
||||
);
|
||||
})
|
||||
.finally(() => setLoadingLogin(false));
|
||||
};
|
||||
|
||||
async function handleSSOAuth() {
|
||||
let isAllowed = false;
|
||||
try {
|
||||
await api.get(`/resource/${props.resource.id}`);
|
||||
isAllowed = true;
|
||||
} catch (e) {
|
||||
setAccessDenied(true);
|
||||
}
|
||||
|
||||
if (isAllowed) {
|
||||
// window.location.href = props.redirect;
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function getTitle() {
|
||||
return t("authenticationRequired");
|
||||
}
|
||||
|
||||
function getSubtitle(resourceName: string) {
|
||||
return numMethods > 1
|
||||
? t("authenticationMethodChoose", { name: props.resource.name })
|
||||
: t("authenticationRequest", { name: props.resource.name });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!accessDenied ? (
|
||||
<div>
|
||||
<div className="text-center mb-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("poweredBy")}{" "}
|
||||
<Link
|
||||
href="https://github.com/fosrl/pangolin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Pangolin
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{getTitle()}</CardTitle>
|
||||
<CardDescription>
|
||||
{getSubtitle(props.resource.name)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
orientation="horizontal"
|
||||
>
|
||||
{numMethods > 1 && (
|
||||
<TabsList
|
||||
className={`grid w-full ${
|
||||
numMethods === 1
|
||||
? "grid-cols-1"
|
||||
: numMethods === 2
|
||||
? "grid-cols-2"
|
||||
: numMethods === 3
|
||||
? "grid-cols-3"
|
||||
: "grid-cols-4"
|
||||
}`}
|
||||
>
|
||||
{props.methods.pincode && (
|
||||
<TabsTrigger value="pin">
|
||||
<Binary className="w-4 h-4 mr-1" />{" "}
|
||||
PIN
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{props.methods.password && (
|
||||
<TabsTrigger value="password">
|
||||
<Key className="w-4 h-4 mr-1" />{" "}
|
||||
{t("password")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{props.methods.sso && (
|
||||
<TabsTrigger value="sso">
|
||||
<User className="w-4 h-4 mr-1" />{" "}
|
||||
{t("user")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{props.methods.whitelist && (
|
||||
<TabsTrigger value="whitelist">
|
||||
<AtSign className="w-4 h-4 mr-1" />{" "}
|
||||
{t("email")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
)}
|
||||
{props.methods.pincode && (
|
||||
<TabsContent
|
||||
value="pin"
|
||||
className={`${numMethods <= 1 ? "mt-0" : ""}`}
|
||||
>
|
||||
<Form {...pinForm}>
|
||||
<form
|
||||
onSubmit={pinForm.handleSubmit(
|
||||
onPinSubmit
|
||||
)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={pinForm.control}
|
||||
name="pin"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"pincodeInput"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={
|
||||
6
|
||||
}
|
||||
{...field}
|
||||
>
|
||||
<InputOTPGroup className="flex">
|
||||
<InputOTPSlot
|
||||
index={
|
||||
0
|
||||
}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
1
|
||||
}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
2
|
||||
}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
3
|
||||
}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
4
|
||||
}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
5
|
||||
}
|
||||
obscured
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{pincodeError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{pincodeError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={loadingLogin}
|
||||
disabled={loadingLogin}
|
||||
>
|
||||
<LockIcon className="w-4 h-4 mr-2" />
|
||||
{t("pincodeSubmit")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</TabsContent>
|
||||
)}
|
||||
{props.methods.password && (
|
||||
<TabsContent
|
||||
value="password"
|
||||
className={`${numMethods <= 1 ? "mt-0" : ""}`}
|
||||
>
|
||||
<Form {...passwordForm}>
|
||||
<form
|
||||
onSubmit={passwordForm.handleSubmit(
|
||||
onPasswordSubmit
|
||||
)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={
|
||||
passwordForm.control
|
||||
}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{passwordError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{passwordError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={loadingLogin}
|
||||
disabled={loadingLogin}
|
||||
>
|
||||
<LockIcon className="w-4 h-4 mr-2" />
|
||||
{t("passwordSubmit")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</TabsContent>
|
||||
)}
|
||||
{props.methods.sso && (
|
||||
<TabsContent
|
||||
value="sso"
|
||||
className={`${numMethods <= 1 ? "mt-0" : ""}`}
|
||||
>
|
||||
<LoginForm
|
||||
idps={props.idps}
|
||||
redirect={props.redirect}
|
||||
onLogin={async () =>
|
||||
await handleSSOAuth()
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
{props.methods.whitelist && (
|
||||
<TabsContent
|
||||
value="whitelist"
|
||||
className={`${numMethods <= 1 ? "mt-0" : ""}`}
|
||||
>
|
||||
{otpState === "idle" && (
|
||||
<Form {...requestOtpForm}>
|
||||
<form
|
||||
onSubmit={requestOtpForm.handleSubmit(
|
||||
onWhitelistSubmit
|
||||
)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={
|
||||
requestOtpForm.control
|
||||
}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("email")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"otpEmailDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{whitelistError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{whitelistError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={loadingLogin}
|
||||
disabled={loadingLogin}
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{t("otpEmailSend")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{otpState === "otp_sent" && (
|
||||
<Form {...submitOtpForm}>
|
||||
<form
|
||||
onSubmit={submitOtpForm.handleSubmit(
|
||||
onWhitelistSubmit
|
||||
)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={
|
||||
submitOtpForm.control
|
||||
}
|
||||
name="otp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"otpEmail"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{whitelistError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{whitelistError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={loadingLogin}
|
||||
disabled={loadingLogin}
|
||||
>
|
||||
<LockIcon className="w-4 h-4 mr-2" />
|
||||
{t("otpEmailSubmit")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setOtpState("idle");
|
||||
submitOtpForm.reset();
|
||||
}}
|
||||
>
|
||||
{t("backToEmail")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{supporterStatus?.visible && (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm text-muted-foreground opacity-50">
|
||||
{t("noSupportKey")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ResourceAccessDenied />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
src/components/ResourceInfoBox.tsx
Normal file
135
src/components/ResourceInfoBox.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
import { toUnicode } from 'punycode';
|
||||
|
||||
type ResourceInfoBoxType = {};
|
||||
|
||||
export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
|
||||
const { resource, authInfo } = useResourceContext();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
|
||||
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={3}>
|
||||
{resource.http ? (
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("authentication")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{authInfo.password ||
|
||||
authInfo.pincode ||
|
||||
authInfo.sso ||
|
||||
authInfo.whitelist ? (
|
||||
<div className="flex items-start space-x-2 text-green-500">
|
||||
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
||||
<span>{t("protected")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2 text-yellow-500">
|
||||
<ShieldOff className="w-4 h-4" />
|
||||
<span>{t("notProtected")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>URL</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={fullUrl}
|
||||
isLink={true}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{/* {isEnabled && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Socket</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{isAvailable ? (
|
||||
<span className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>Online</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<span>Offline</span>
|
||||
</span>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)} */}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("protocol")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span>
|
||||
{resource.protocol.toUpperCase()}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("port")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={resource.proxyPort!.toString()}
|
||||
isLink={false}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{/* {build == "oss" && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("externalProxyEnabled")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span>
|
||||
{resource.enableProxy
|
||||
? t("enabled")
|
||||
: t("disabled")}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)} */}
|
||||
</>
|
||||
)}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span>
|
||||
{resource.enabled
|
||||
? t("enabled")
|
||||
: t("disabled")}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
33
src/components/ResourceNotFound.tsx
Normal file
33
src/components/ResourceNotFound.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@app/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export default async function ResourceNotFound() {
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-2xl font-bold">
|
||||
{t('resourceNotFound')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{t('resourceNotFoundDescription')}
|
||||
<div className="text-center mt-4">
|
||||
<Button>
|
||||
<Link href="/">{t('goHome')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
954
src/components/ResourcesTable.tsx
Normal file
954
src/components/ResourcesTable.tsx
Normal file
@@ -0,0 +1,954 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getPaginationRowModel,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
MoreHorizontal,
|
||||
ArrowUpRight,
|
||||
ShieldOff,
|
||||
ShieldCheck
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@app/components/ui/table";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger
|
||||
} from "@app/components/ui/tabs";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
||||
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
|
||||
export type ResourceRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
orgId: string;
|
||||
domain: string;
|
||||
authState: string;
|
||||
http: boolean;
|
||||
protocol: string;
|
||||
proxyPort: number | null;
|
||||
enabled: boolean;
|
||||
domainId?: string;
|
||||
};
|
||||
|
||||
export type InternalResourceRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
orgId: string;
|
||||
siteName: string;
|
||||
protocol: string;
|
||||
proxyPort: number | null;
|
||||
siteId: number;
|
||||
siteNiceId: string;
|
||||
destinationIp: string;
|
||||
destinationPort: number;
|
||||
};
|
||||
|
||||
type Site = ListSitesResponse["sites"][0];
|
||||
|
||||
type ResourcesTableProps = {
|
||||
resources: ResourceRow[];
|
||||
internalResources: InternalResourceRow[];
|
||||
orgId: string;
|
||||
defaultView?: "proxy" | "internal";
|
||||
};
|
||||
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
PAGE_SIZE: 'datatable-page-size',
|
||||
getTablePageSize: (tableId?: string) =>
|
||||
tableId ? `datatable-${tableId}-page-size` : STORAGE_KEYS.PAGE_SIZE
|
||||
};
|
||||
|
||||
const getStoredPageSize = (tableId?: string, defaultSize = 20): number => {
|
||||
if (typeof window === 'undefined') return defaultSize;
|
||||
|
||||
try {
|
||||
const key = STORAGE_KEYS.getTablePageSize(tableId);
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
const parsed = parseInt(stored, 10);
|
||||
if (parsed > 0 && parsed <= 1000) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to read page size from localStorage:', error);
|
||||
}
|
||||
return defaultSize;
|
||||
};
|
||||
|
||||
const setStoredPageSize = (pageSize: number, tableId?: string): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const key = STORAGE_KEYS.getTablePageSize(tableId);
|
||||
localStorage.setItem(key, pageSize.toString());
|
||||
} catch (error) {
|
||||
console.warn('Failed to save page size to localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export default function ResourcesTable({
|
||||
resources,
|
||||
internalResources,
|
||||
orgId,
|
||||
defaultView = "proxy"
|
||||
}: ResourcesTableProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [proxyPageSize, setProxyPageSize] = useState<number>(() =>
|
||||
getStoredPageSize('proxy-resources', 20)
|
||||
);
|
||||
const [internalPageSize, setInternalPageSize] = useState<number>(() =>
|
||||
getStoredPageSize('internal-resources', 20)
|
||||
);
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedResource, setSelectedResource] =
|
||||
useState<ResourceRow | null>();
|
||||
const [selectedInternalResource, setSelectedInternalResource] =
|
||||
useState<InternalResourceRow | null>();
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [editingResource, setEditingResource] =
|
||||
useState<InternalResourceRow | null>();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
|
||||
const [proxySorting, setProxySorting] = useState<SortingState>([]);
|
||||
const [proxyColumnFilters, setProxyColumnFilters] =
|
||||
useState<ColumnFiltersState>([]);
|
||||
const [proxyGlobalFilter, setProxyGlobalFilter] = useState<any>([]);
|
||||
|
||||
const [internalSorting, setInternalSorting] = useState<SortingState>([]);
|
||||
const [internalColumnFilters, setInternalColumnFilters] =
|
||||
useState<ColumnFiltersState>([]);
|
||||
const [internalGlobalFilter, setInternalGlobalFilter] = useState<any>([]);
|
||||
|
||||
const currentView = searchParams.get("view") || defaultView;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSites = async () => {
|
||||
try {
|
||||
const res = await api.get<AxiosResponse<ListSitesResponse>>(
|
||||
`/org/${orgId}/sites`
|
||||
);
|
||||
setSites(res.data.data.sites);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch sites:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (orgId) {
|
||||
fetchSites();
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (value === "internal") {
|
||||
params.set("view", "internal");
|
||||
} else {
|
||||
params.delete("view");
|
||||
}
|
||||
|
||||
const newUrl = `${window.location.pathname}${params.toString() ? "?" + params.toString() : ""}`;
|
||||
router.replace(newUrl, { scroll: false });
|
||||
};
|
||||
|
||||
const getSearchInput = () => {
|
||||
if (currentView === "internal") {
|
||||
return (
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Input
|
||||
placeholder={t("resourcesSearch")}
|
||||
value={internalGlobalFilter ?? ""}
|
||||
onChange={(e) =>
|
||||
internalTable.setGlobalFilter(
|
||||
String(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full pl-8"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Input
|
||||
placeholder={t("resourcesSearch")}
|
||||
value={proxyGlobalFilter ?? ""}
|
||||
onChange={(e) =>
|
||||
proxyTable.setGlobalFilter(String(e.target.value))
|
||||
}
|
||||
className="w-full pl-8"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getActionButton = () => {
|
||||
if (currentView === "internal") {
|
||||
return (
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("resourceAdd")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
router.push(`/${orgId}/settings/resources/create`)
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("resourceAdd")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const deleteResource = (resourceId: number) => {
|
||||
api.delete(`/resource/${resourceId}`)
|
||||
.catch((e) => {
|
||||
console.error(t("resourceErrorDelte"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorDelte"),
|
||||
description: formatAxiosError(e, t("resourceErrorDelte"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteInternalResource = async (
|
||||
resourceId: number,
|
||||
siteId: number
|
||||
) => {
|
||||
try {
|
||||
await api.delete(
|
||||
`/org/${orgId}/site/${siteId}/resource/${resourceId}`
|
||||
);
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
} catch (e) {
|
||||
console.error(t("resourceErrorDelete"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorDelte"),
|
||||
description: formatAxiosError(e, t("v"))
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
async function toggleResourceEnabled(val: boolean, resourceId: number) {
|
||||
const res = await api
|
||||
.post<AxiosResponse<UpdateResourceResponse>>(
|
||||
`resource/${resourceId}`,
|
||||
{
|
||||
enabled: val
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourcesErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("resourcesErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const proxyColumns: ColumnDef<ResourceRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "protocol",
|
||||
header: t("protocol"),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return <span>{resourceRow.protocol.toUpperCase()}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "domain",
|
||||
header: t("access"),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{!resourceRow.http ? (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.proxyPort!.toString()}
|
||||
isLink={false}
|
||||
/>
|
||||
) : !resourceRow.domainId ? (
|
||||
<InfoPopup
|
||||
info={t("domainNotFoundDescription")}
|
||||
text={t("domainNotFound")}
|
||||
/>
|
||||
) : (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.domain}
|
||||
isLink={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "authState",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("authentication")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<div>
|
||||
{resourceRow.authState === "protected" ? (
|
||||
<span className="text-green-500 flex items-center space-x-2">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<span>{t("protected")}</span>
|
||||
</span>
|
||||
) : resourceRow.authState === "not_protected" ? (
|
||||
<span className="text-yellow-500 flex items-center space-x-2">
|
||||
<ShieldOff className="w-4 h-4" />
|
||||
<span>{t("notProtected")}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>-</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
header: t("enabled"),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={
|
||||
row.original.http
|
||||
? !!row.original.domainId && row.original.enabled
|
||||
: row.original.enabled
|
||||
}
|
||||
disabled={
|
||||
row.original.http ? !row.original.domainId : false
|
||||
}
|
||||
onCheckedChange={(val) =>
|
||||
toggleResourceEnabled(val, row.original.id)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{t("viewSettings")}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedResource(resourceRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
|
||||
>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
className="ml-2"
|
||||
size="sm"
|
||||
>
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const internalColumns: ColumnDef<InternalResourceRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "siteName",
|
||||
header: t("siteName"),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<Link
|
||||
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteNiceId}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{resourceRow.siteName}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "protocol",
|
||||
header: t("protocol"),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return <span>{resourceRow.protocol.toUpperCase()}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "proxyPort",
|
||||
header: t("proxyPort"),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.proxyPort!.toString()}
|
||||
isLink={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "destination",
|
||||
header: t("resourcesTableDestination"),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
const destination = `${resourceRow.destinationIp}:${resourceRow.destinationPort}`;
|
||||
return <CopyToClipboard text={destination} isLink={false} />;
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedInternalResource(
|
||||
resourceRow
|
||||
);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingResource(resourceRow);
|
||||
setIsEditDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const proxyTable = useReactTable({
|
||||
data: resources,
|
||||
columns: proxyColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setProxySorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setProxyColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onGlobalFilterChange: setProxyGlobalFilter,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: proxyPageSize,
|
||||
pageIndex: 0
|
||||
}
|
||||
},
|
||||
state: {
|
||||
sorting: proxySorting,
|
||||
columnFilters: proxyColumnFilters,
|
||||
globalFilter: proxyGlobalFilter
|
||||
}
|
||||
});
|
||||
|
||||
const internalTable = useReactTable({
|
||||
data: internalResources,
|
||||
columns: internalColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setInternalSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setInternalColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onGlobalFilterChange: setInternalGlobalFilter,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: internalPageSize,
|
||||
pageIndex: 0
|
||||
}
|
||||
},
|
||||
state: {
|
||||
sorting: internalSorting,
|
||||
columnFilters: internalColumnFilters,
|
||||
globalFilter: internalGlobalFilter
|
||||
}
|
||||
});
|
||||
|
||||
const handleProxyPageSizeChange = (newPageSize: number) => {
|
||||
setProxyPageSize(newPageSize);
|
||||
setStoredPageSize(newPageSize, 'proxy-resources');
|
||||
};
|
||||
|
||||
const handleInternalPageSizeChange = (newPageSize: number) => {
|
||||
setInternalPageSize(newPageSize);
|
||||
setStoredPageSize(newPageSize, 'internal-resources');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedResource && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedResource(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<p className="mb-2">
|
||||
{t("resourceQuestionRemove", {
|
||||
selectedResource:
|
||||
selectedResource?.name ||
|
||||
selectedResource?.id
|
||||
})}
|
||||
</p>
|
||||
|
||||
<p className="mb-2">{t("resourceMessageRemove")}</p>
|
||||
|
||||
<p>{t("resourceMessageConfirm")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("resourceDeleteConfirm")}
|
||||
onConfirm={async () => deleteResource(selectedResource!.id)}
|
||||
string={selectedResource.name}
|
||||
title={t("resourceDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedInternalResource && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedInternalResource(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<p className="mb-2">
|
||||
{t("resourceQuestionRemove", {
|
||||
selectedResource:
|
||||
selectedInternalResource?.name ||
|
||||
selectedInternalResource?.id
|
||||
})}
|
||||
</p>
|
||||
|
||||
<p className="mb-2">{t("resourceMessageRemove")}</p>
|
||||
|
||||
<p>{t("resourceMessageConfirm")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("resourceDeleteConfirm")}
|
||||
onConfirm={async () =>
|
||||
deleteInternalResource(
|
||||
selectedInternalResource!.id,
|
||||
selectedInternalResource!.siteId
|
||||
)
|
||||
}
|
||||
string={selectedInternalResource.name}
|
||||
title={t("resourceDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<Card>
|
||||
<Tabs
|
||||
defaultValue={defaultView}
|
||||
className="w-full"
|
||||
onValueChange={handleTabChange}
|
||||
>
|
||||
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-0">
|
||||
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
|
||||
{getSearchInput()}
|
||||
|
||||
{env.flags.enableClients && (
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="proxy">
|
||||
{t("resourcesTableProxyResources")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="internal">
|
||||
{t("resourcesTableClientResources")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:justify-end">
|
||||
{getActionButton()}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TabsContent value="proxy">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{proxyTable
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map(
|
||||
(header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{proxyTable.getRowModel().rows
|
||||
?.length ? (
|
||||
proxyTable
|
||||
.getRowModel()
|
||||
.rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
"selected"
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={
|
||||
cell.id
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell
|
||||
.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={
|
||||
proxyColumns.length
|
||||
}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t(
|
||||
"resourcesTableNoProxyResourcesFound"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination
|
||||
table={proxyTable}
|
||||
onPageSizeChange={handleProxyPageSizeChange}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="internal">
|
||||
<div className="mb-4">
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"resourcesTableTheseResourcesForUseWith"
|
||||
)}{" "}
|
||||
<Link
|
||||
href={`/${orgId}/settings/clients`}
|
||||
className="font-medium underline hover:opacity-80 inline-flex items-center"
|
||||
>
|
||||
{t("resourcesTableClients")}
|
||||
<ArrowUpRight className="ml-1 h-3 w-3" />
|
||||
</Link>{" "}
|
||||
{t(
|
||||
"resourcesTableAndOnlyAccessibleInternally"
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{internalTable
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map(
|
||||
(header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{internalTable.getRowModel().rows
|
||||
?.length ? (
|
||||
internalTable
|
||||
.getRowModel()
|
||||
.rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
"selected"
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={
|
||||
cell.id
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell
|
||||
.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={
|
||||
internalColumns.length
|
||||
}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t(
|
||||
"resourcesTableNoInternalResourcesFound"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination
|
||||
table={internalTable}
|
||||
onPageSizeChange={handleInternalPageSizeChange}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</CardContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{editingResource && (
|
||||
<EditInternalResourceDialog
|
||||
open={isEditDialogOpen}
|
||||
setOpen={setIsEditDialogOpen}
|
||||
resource={editingResource}
|
||||
orgId={orgId}
|
||||
onSuccess={() => {
|
||||
router.refresh();
|
||||
setEditingResource(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateInternalResourceDialog
|
||||
open={isCreateDialogOpen}
|
||||
setOpen={setIsCreateDialogOpen}
|
||||
orgId={orgId}
|
||||
sites={sites}
|
||||
onSuccess={() => {
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
35
src/components/RolesDataTable.tsx
Normal file
35
src/components/RolesDataTable.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
createRole?: () => void;
|
||||
}
|
||||
|
||||
export function RolesDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
createRole
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="roles-table"
|
||||
title={t('roles')}
|
||||
searchPlaceholder={t('accessRolesSearch')}
|
||||
searchColumn="name"
|
||||
onAdd={createRole}
|
||||
addButtonText={t('accessRolesAdd')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
122
src/components/RolesTable.tsx
Normal file
122
src/components/RolesTable.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { RolesDataTable } from "@app/components/RolesDataTable";
|
||||
import { Role } from "@server/db";
|
||||
import CreateRoleForm from "@app/components/CreateRoleForm";
|
||||
import DeleteRoleForm from "@app/components/DeleteRoleForm";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type RoleRow = Role;
|
||||
|
||||
type RolesTableProps = {
|
||||
roles: RoleRow[];
|
||||
};
|
||||
|
||||
export default function UsersTable({ roles: r }: RolesTableProps) {
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
|
||||
const [roles, setRoles] = useState<RoleRow[]>(r);
|
||||
|
||||
const [roleToRemove, setUserToRemove] = useState<RoleRow | null>(null);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const columns: ColumnDef<RoleRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: t("description")
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const roleRow = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size="sm"
|
||||
disabled={roleRow.isAdmin || false}
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setUserToRemove(roleRow);
|
||||
}}
|
||||
>
|
||||
{t("accessRoleDelete")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateRoleForm
|
||||
open={isCreateModalOpen}
|
||||
setOpen={setIsCreateModalOpen}
|
||||
afterCreate={async (role) => {
|
||||
setRoles((prev) => [...prev, role]);
|
||||
}}
|
||||
/>
|
||||
|
||||
{roleToRemove && (
|
||||
<DeleteRoleForm
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={setIsDeleteModalOpen}
|
||||
roleToDelete={roleToRemove}
|
||||
afterDelete={() => {
|
||||
setRoles((prev) =>
|
||||
prev.filter((r) => r.roleId !== roleToRemove.roleId)
|
||||
);
|
||||
setUserToRemove(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RolesDataTable
|
||||
columns={columns}
|
||||
data={roles}
|
||||
createRole={() => {
|
||||
setIsCreateModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
167
src/components/SetResourcePasswordForm.tsx
Normal file
167
src/components/SetResourcePasswordForm.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { Resource } from "@server/db";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const setPasswordFormSchema = z.object({
|
||||
password: z.string().min(4).max(100)
|
||||
});
|
||||
|
||||
type SetPasswordFormValues = z.infer<typeof setPasswordFormSchema>;
|
||||
|
||||
const defaultValues: Partial<SetPasswordFormValues> = {
|
||||
password: ""
|
||||
};
|
||||
|
||||
type SetPasswordFormProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
resourceId: number;
|
||||
onSetPassword?: () => void;
|
||||
};
|
||||
|
||||
export default function SetResourcePasswordForm({
|
||||
open,
|
||||
setOpen,
|
||||
resourceId,
|
||||
onSetPassword
|
||||
}: SetPasswordFormProps) {
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const form = useForm<SetPasswordFormValues>({
|
||||
resolver: zodResolver(setPasswordFormSchema),
|
||||
defaultValues
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset();
|
||||
}, [open]);
|
||||
|
||||
async function onSubmit(data: SetPasswordFormValues) {
|
||||
setLoading(true);
|
||||
|
||||
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/password`, {
|
||||
password: data.password
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('resourceErrorPasswordSetup'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('resourceErrorPasswordSetupDescription')
|
||||
)
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: t('resourcePasswordSetup'),
|
||||
description: t('resourcePasswordSetupDescription')
|
||||
});
|
||||
|
||||
if (onSetPassword) {
|
||||
onSetPassword();
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
setLoading(false);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t('resourcePasswordSetupTitle')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('resourcePasswordSetupTitleDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="set-password-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('password')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="set-password-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('resourcePasswordSubmit')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
</>
|
||||
);
|
||||
}
|
||||
202
src/components/SetResourcePincodeForm.tsx
Normal file
202
src/components/SetResourcePincodeForm.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { Resource } from "@server/db";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot
|
||||
} from "@app/components/ui/input-otp";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const setPincodeFormSchema = z.object({
|
||||
pincode: z.string().length(6)
|
||||
});
|
||||
|
||||
type SetPincodeFormValues = z.infer<typeof setPincodeFormSchema>;
|
||||
|
||||
const defaultValues: Partial<SetPincodeFormValues> = {
|
||||
pincode: ""
|
||||
};
|
||||
|
||||
type SetPincodeFormProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
resourceId: number;
|
||||
onSetPincode?: () => void;
|
||||
};
|
||||
|
||||
export default function SetResourcePincodeForm({
|
||||
open,
|
||||
setOpen,
|
||||
resourceId,
|
||||
onSetPincode
|
||||
}: SetPincodeFormProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const form = useForm<SetPincodeFormValues>({
|
||||
resolver: zodResolver(setPincodeFormSchema),
|
||||
defaultValues
|
||||
});
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset();
|
||||
}, [open]);
|
||||
|
||||
async function onSubmit(data: SetPincodeFormValues) {
|
||||
setLoading(true);
|
||||
|
||||
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/pincode`, {
|
||||
pincode: data.pincode
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('resourceErrorPincodeSetup'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('resourceErrorPincodeSetupDescription')
|
||||
)
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: t('resourcePincodeSetup'),
|
||||
description: t('resourcePincodeSetupDescription')
|
||||
});
|
||||
|
||||
if (onSetPincode) {
|
||||
onSetPincode();
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
setLoading(false);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t('resourcePincodeSetupTitle')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('resourcePincodeSetupTitleDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="set-pincode-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="pincode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('resourcePincode')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
autoComplete="false"
|
||||
maxLength={6}
|
||||
{...field}
|
||||
>
|
||||
<InputOTPGroup className="flex">
|
||||
<InputOTPSlot
|
||||
index={0}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={1}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={2}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={3}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={4}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={5}
|
||||
obscured
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="set-pincode-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('resourcePincodeSubmit')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
</>
|
||||
);
|
||||
}
|
||||
35
src/components/ShareLinksDataTable.tsx
Normal file
35
src/components/ShareLinksDataTable.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
createShareLink?: () => void;
|
||||
}
|
||||
|
||||
export function ShareLinksDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
createShareLink
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="shareLinks-table"
|
||||
title={t('shareLinks')}
|
||||
searchPlaceholder={t('shareSearch')}
|
||||
searchColumn="name"
|
||||
onAdd={createShareLink}
|
||||
addButtonText={t('shareCreate')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
70
src/components/ShareLinksSplash.tsx
Normal file
70
src/components/ShareLinksSplash.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Link, X, Clock, Share, ArrowRight, Lock } from "lucide-react"; // Replace with actual imports
|
||||
import { Card, CardContent } from "@app/components/ui/card";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export const ShareableLinksSplash = () => {
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
||||
const key = "share-links-splash-dismissed";
|
||||
|
||||
useEffect(() => {
|
||||
const dismissed = localStorage.getItem(key);
|
||||
if (dismissed === "true") {
|
||||
setIsDismissed(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsDismissed(true);
|
||||
localStorage.setItem(key, "true");
|
||||
};
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
if (isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full mx-auto overflow-hidden mb-8 hidden md:block relative">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-2 right-2 p-2"
|
||||
aria-label={t('dismiss')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<CardContent className="grid gap-6 p-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Link className="text-blue-500" />
|
||||
{t('share')}
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
{t('shareDescription2')}
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li className="flex items-center gap-2">
|
||||
<Share className="text-green-500 w-4 h-4" />
|
||||
{t('shareEasyCreate')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Clock className="text-yellow-500 w-4 h-4" />
|
||||
{t('shareConfigurableExpirationDuration')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Lock className="text-red-500 w-4 h-4" />
|
||||
{t('shareSecureAndRevocable')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareableLinksSplash;
|
||||
298
src/components/ShareLinksTable.tsx
Normal file
298
src/components/ShareLinksTable.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { ShareLinksDataTable } from "@app/components/ShareLinksDataTable";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Copy,
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
MoreHorizontal,
|
||||
Check,
|
||||
ArrowUpRight,
|
||||
ShieldOff,
|
||||
ShieldCheck
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
// import CreateResourceForm from "./CreateResourceForm";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { ArrayElement } from "@server/types/ArrayElement";
|
||||
import { ListAccessTokensResponse } from "@server/routers/accessToken";
|
||||
import moment from "moment";
|
||||
import CreateShareLinkForm from "@app/components/CreateShareLinkForm";
|
||||
import { constructShareLink } from "@app/lib/shareLinks";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type ShareLinkRow = {
|
||||
accessTokenId: string;
|
||||
resourceId: number;
|
||||
resourceName: string;
|
||||
title: string | null;
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
};
|
||||
|
||||
type ShareLinksTableProps = {
|
||||
shareLinks: ShareLinkRow[];
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export default function ShareLinksTable({
|
||||
shareLinks,
|
||||
orgId
|
||||
}: ShareLinksTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [rows, setRows] = useState<ShareLinkRow[]>(shareLinks);
|
||||
|
||||
function formatLink(link: string) {
|
||||
return link.substring(0, 20) + "..." + link.substring(link.length - 20);
|
||||
}
|
||||
|
||||
async function deleteSharelink(id: string) {
|
||||
await api.delete(`/access-token/${id}`).catch((e) => {
|
||||
toast({
|
||||
title: t("shareErrorDelete"),
|
||||
description: formatAxiosError(e, t("shareErrorDeleteMessage"))
|
||||
});
|
||||
});
|
||||
|
||||
const newRows = rows.filter((r) => r.accessTokenId !== id);
|
||||
setRows(newRows);
|
||||
|
||||
toast({
|
||||
title: t("shareDeleted"),
|
||||
description: t("shareDeletedDescription")
|
||||
});
|
||||
}
|
||||
|
||||
const columns: ColumnDef<ShareLinkRow>[] = [
|
||||
{
|
||||
accessorKey: "resourceName",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("resource")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Link href={`/${orgId}/settings/resources/${r.resourceId}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
{r.resourceName}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("title")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
// {
|
||||
// accessorKey: "domain",
|
||||
// header: "Link",
|
||||
// cell: ({ row }) => {
|
||||
// const r = row.original;
|
||||
//
|
||||
// const link = constructShareLink(
|
||||
// r.resourceId,
|
||||
// r.accessTokenId,
|
||||
// r.tokenHash
|
||||
// );
|
||||
//
|
||||
// return (
|
||||
// <div className="flex items-center">
|
||||
// <Link
|
||||
// href={link}
|
||||
// target="_blank"
|
||||
// rel="noopener noreferrer"
|
||||
// className="hover:underline mr-2"
|
||||
// >
|
||||
// {formatLink(link)}
|
||||
// </Link>
|
||||
// <Button
|
||||
// variant="ghost"
|
||||
// className="h-6 w-6 p-0"
|
||||
// onClick={() => {
|
||||
// navigator.clipboard.writeText(link);
|
||||
// const originalIcon = document.querySelector(
|
||||
// `#icon-${r.accessTokenId}`
|
||||
// );
|
||||
// if (originalIcon) {
|
||||
// originalIcon.classList.add("hidden");
|
||||
// }
|
||||
// const checkIcon = document.querySelector(
|
||||
// `#check-icon-${r.accessTokenId}`
|
||||
// );
|
||||
// if (checkIcon) {
|
||||
// checkIcon.classList.remove("hidden");
|
||||
// setTimeout(() => {
|
||||
// checkIcon.classList.add("hidden");
|
||||
// if (originalIcon) {
|
||||
// originalIcon.classList.remove(
|
||||
// "hidden"
|
||||
// );
|
||||
// }
|
||||
// }, 2000);
|
||||
// }
|
||||
// }}
|
||||
// >
|
||||
// <Copy
|
||||
// id={`icon-${r.accessTokenId}`}
|
||||
// className="h-4 w-4"
|
||||
// />
|
||||
// <Check
|
||||
// id={`check-icon-${r.accessTokenId}`}
|
||||
// className="hidden text-green-500 h-4 w-4"
|
||||
// />
|
||||
// <span className="sr-only">Copy link</span>
|
||||
// </Button>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
// },
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("created")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return moment(r.createdAt).format("lll");
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "expiresAt",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("expires")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
if (r.expiresAt) {
|
||||
return moment(r.expiresAt).format("lll");
|
||||
}
|
||||
return t("never");
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
{/* <DropdownMenu> */}
|
||||
{/* <DropdownMenuTrigger asChild> */}
|
||||
{/* <Button variant="ghost" className="h-8 w-8 p-0"> */}
|
||||
{/* <span className="sr-only"> */}
|
||||
{/* {t("openMenu")} */}
|
||||
{/* </span> */}
|
||||
{/* <MoreHorizontal className="h-4 w-4" /> */}
|
||||
{/* </Button> */}
|
||||
{/* </DropdownMenuTrigger> */}
|
||||
{/* <DropdownMenuContent align="end"> */}
|
||||
{/* <DropdownMenuItem */}
|
||||
{/* onClick={() => { */}
|
||||
{/* deleteSharelink( */}
|
||||
{/* resourceRow.accessTokenId */}
|
||||
{/* ); */}
|
||||
{/* }} */}
|
||||
{/* > */}
|
||||
{/* <button className="text-red-500"> */}
|
||||
{/* {t("delete")} */}
|
||||
{/* </button> */}
|
||||
{/* </DropdownMenuItem> */}
|
||||
{/* </DropdownMenuContent> */}
|
||||
{/* </DropdownMenu> */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
deleteSharelink(row.original.accessTokenId)
|
||||
}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateShareLinkForm
|
||||
open={isCreateModalOpen}
|
||||
setOpen={setIsCreateModalOpen}
|
||||
onCreated={(val) => {
|
||||
setRows([val, ...rows]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ShareLinksDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
createShareLink={() => {
|
||||
setIsCreateModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
454
src/components/SignupForm.tsx
Normal file
454
src/components/SignupForm.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { SignUpResponse } from "@server/routers/auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import Image from "next/image";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { useTranslations } from "next-intl";
|
||||
import BrandingLogo from "@app/components/BrandingLogo";
|
||||
import { build } from "@server/build";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
// Password strength calculation
|
||||
const calculatePasswordStrength = (password: string) => {
|
||||
const requirements = {
|
||||
length: password.length >= 8,
|
||||
uppercase: /[A-Z]/.test(password),
|
||||
lowercase: /[a-z]/.test(password),
|
||||
number: /[0-9]/.test(password),
|
||||
special: /[~!`@#$%^&*()_\-+={}[\]|\\:;"'<>,.\/?]/.test(password)
|
||||
};
|
||||
|
||||
const score = Object.values(requirements).filter(Boolean).length;
|
||||
let strength: "weak" | "medium" | "strong" = "weak";
|
||||
let color = "bg-red-500";
|
||||
let percentage = 0;
|
||||
|
||||
if (score >= 5) {
|
||||
strength = "strong";
|
||||
color = "bg-green-500";
|
||||
percentage = 100;
|
||||
} else if (score >= 3) {
|
||||
strength = "medium";
|
||||
color = "bg-yellow-500";
|
||||
percentage = 60;
|
||||
} else if (score >= 1) {
|
||||
strength = "weak";
|
||||
color = "bg-red-500";
|
||||
percentage = 30;
|
||||
}
|
||||
|
||||
return { requirements, strength, color, percentage, score };
|
||||
};
|
||||
|
||||
type SignupFormProps = {
|
||||
redirect?: string;
|
||||
inviteId?: string;
|
||||
inviteToken?: string;
|
||||
emailParam?: string;
|
||||
};
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
email: z.string().email({ message: "Invalid email address" }),
|
||||
password: passwordSchema,
|
||||
confirmPassword: passwordSchema,
|
||||
agreeToTerms: z.boolean().refine(
|
||||
(val) => {
|
||||
if (build === "saas") {
|
||||
val === true;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"You must agree to the terms of service and privacy policy"
|
||||
}
|
||||
)
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: "Passwords do not match"
|
||||
});
|
||||
|
||||
export default function SignupForm({
|
||||
redirect,
|
||||
inviteId,
|
||||
inviteToken,
|
||||
emailParam
|
||||
}: SignupFormProps) {
|
||||
const router = useRouter();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [termsAgreedAt, setTermsAgreedAt] = useState<string | null>(null);
|
||||
const [passwordValue, setPasswordValue] = useState("");
|
||||
const [confirmPasswordValue, setConfirmPasswordValue] = useState("");
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: emailParam || "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
agreeToTerms: false
|
||||
},
|
||||
mode: "onChange" // Enable real-time validation
|
||||
});
|
||||
|
||||
const passwordStrength = calculatePasswordStrength(passwordValue);
|
||||
const doPasswordsMatch = passwordValue.length > 0 && confirmPasswordValue.length > 0 && passwordValue === confirmPasswordValue;
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
const { email, password } = values;
|
||||
|
||||
setLoading(true);
|
||||
const res = await api
|
||||
.put<AxiosResponse<SignUpResponse>>("/auth/signup", {
|
||||
email,
|
||||
password,
|
||||
inviteId,
|
||||
inviteToken,
|
||||
termsAcceptedTimestamp: termsAgreedAt
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setError(formatAxiosError(e, t("signupError")));
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
setError(null);
|
||||
|
||||
if (res.data?.data?.emailVerificationRequired) {
|
||||
if (redirect) {
|
||||
const safe = cleanRedirect(redirect);
|
||||
router.push(`/auth/verify-email?redirect=${safe}`);
|
||||
} else {
|
||||
router.push("/auth/verify-email");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (redirect) {
|
||||
const safe = cleanRedirect(redirect);
|
||||
router.push(safe);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function getSubtitle() {
|
||||
return t("authCreateAccount");
|
||||
}
|
||||
|
||||
const handleTermsChange = (checked: boolean) => {
|
||||
if (checked) {
|
||||
const isoNow = new Date().toISOString();
|
||||
console.log("Terms agreed at:", isoNow);
|
||||
setTermsAgreedAt(isoNow);
|
||||
form.setValue("agreeToTerms", true);
|
||||
} else {
|
||||
form.setValue("agreeToTerms", false);
|
||||
setTermsAgreedAt(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md shadow-md">
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<BrandingLogo height={58} width={175} />
|
||||
</div>
|
||||
<div className="text-center space-y-1 pt-3">
|
||||
<p className="text-muted-foreground">{getSubtitle()}</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={!!emailParam}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>{t("password")}</FormLabel>
|
||||
{passwordStrength.strength === "strong" && (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
setPasswordValue(e.target.value);
|
||||
}}
|
||||
className={cn(
|
||||
passwordStrength.strength === "strong" && "border-green-500 focus-visible:ring-green-500",
|
||||
passwordStrength.strength === "medium" && "border-yellow-500 focus-visible:ring-yellow-500",
|
||||
passwordStrength.strength === "weak" && passwordValue.length > 0 && "border-red-500 focus-visible:ring-red-500"
|
||||
)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
{passwordValue.length > 0 && (
|
||||
<div className="space-y-3 mt-2">
|
||||
{/* Password Strength Meter */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-foreground">{t("passwordStrength")}</span>
|
||||
<span className={cn(
|
||||
"text-sm font-semibold",
|
||||
passwordStrength.strength === "strong" && "text-green-600 dark:text-green-400",
|
||||
passwordStrength.strength === "medium" && "text-yellow-600 dark:text-yellow-400",
|
||||
passwordStrength.strength === "weak" && "text-red-600 dark:text-red-400"
|
||||
)}>
|
||||
{t(`passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}`)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={passwordStrength.percentage}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Requirements Checklist */}
|
||||
<div className="bg-muted rounded-lg p-3 space-y-2">
|
||||
<div className="text-sm font-medium text-foreground mb-2">{t("passwordRequirements")}</div>
|
||||
<div className="grid grid-cols-1 gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength.requirements.length ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
passwordStrength.requirements.length ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
{t("passwordRequirementLengthText")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength.requirements.uppercase ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
passwordStrength.requirements.uppercase ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
{t("passwordRequirementUppercaseText")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength.requirements.lowercase ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
passwordStrength.requirements.lowercase ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
{t("passwordRequirementLowercaseText")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength.requirements.number ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
passwordStrength.requirements.number ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
{t("passwordRequirementNumberText")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength.requirements.special ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
passwordStrength.requirements.special ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
{t("passwordRequirementSpecialText")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Only show FormMessage when not showing our custom requirements */}
|
||||
{passwordValue.length === 0 && <FormMessage />}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>{t('confirmPassword')}</FormLabel>
|
||||
{doPasswordsMatch && (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
setConfirmPasswordValue(e.target.value);
|
||||
}}
|
||||
className={cn(
|
||||
doPasswordsMatch && "border-green-500 focus-visible:ring-green-500",
|
||||
confirmPasswordValue.length > 0 && !doPasswordsMatch && "border-red-500 focus-visible:ring-red-500"
|
||||
)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
{confirmPasswordValue.length > 0 && !doPasswordsMatch && (
|
||||
<p className="text-sm text-red-600 mt-1">
|
||||
{t("passwordsDoNotMatch")}
|
||||
</p>
|
||||
)}
|
||||
{/* Only show FormMessage when field is empty */}
|
||||
{confirmPasswordValue.length === 0 && <FormMessage />}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{build === "saas" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="agreeToTerms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
handleTermsChange(
|
||||
checked as boolean
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
{t("signUpTerms.IAgreeToThe")}
|
||||
<a
|
||||
href="https://digpangolin.com/terms-of-service.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.termsOfService"
|
||||
)}
|
||||
</a>
|
||||
{t("signUpTerms.and")}
|
||||
<a
|
||||
href="https://digpangolin.com/privacy-policy.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.privacyPolicy"
|
||||
)}
|
||||
</a>
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
{t("createAccount")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
81
src/components/SiteInfoCard.tsx
Normal file
81
src/components/SiteInfoCard.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useSiteContext } from "@app/hooks/useSiteContext";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
type SiteInfoCardProps = {};
|
||||
|
||||
export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
const { site, updateSite } = useSiteContext();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const getConnectionTypeString = (type: string) => {
|
||||
if (type === "newt") {
|
||||
return "Newt";
|
||||
} else if (type === "wireguard") {
|
||||
return "WireGuard";
|
||||
} else if (type === "local") {
|
||||
return t("local");
|
||||
} else {
|
||||
return t("unknown");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={env.flags.enableClients ? 3 : 2}>
|
||||
{(site.type == "newt" || site.type == "wireguard") && (
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("status")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{site.online ? (
|
||||
<div className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>{t("online")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<span>{t("offline")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</>
|
||||
)}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("connectionType")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{getConnectionTypeString(site.type)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
|
||||
{env.flags.enableClients && site.type == "newt" && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Address</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{site.address?.split("/")[0]}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
131
src/components/SitePriceCalculator.tsx
Normal file
131
src/components/SitePriceCalculator.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { MinusCircle, PlusCircle } from "lucide-react";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type SitePriceCalculatorProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: "license" | "additional-sites";
|
||||
};
|
||||
|
||||
export function SitePriceCalculator({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
mode
|
||||
}: SitePriceCalculatorProps) {
|
||||
const [siteCount, setSiteCount] = useState(3);
|
||||
const pricePerSite = 5;
|
||||
const licenseFlatRate = 125;
|
||||
|
||||
const incrementSites = () => {
|
||||
setSiteCount((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const decrementSites = () => {
|
||||
setSiteCount((prev) => (prev > 1 ? prev - 1 : 1));
|
||||
};
|
||||
|
||||
function continueToPayment() {
|
||||
if (mode === "license") {
|
||||
// open in new tab
|
||||
window.open(
|
||||
`https://payment.fossorial.io/buy/dab98d3d-9976-49b1-9e55-1580059d833f?quantity=${siteCount}`,
|
||||
"_blank"
|
||||
);
|
||||
} else {
|
||||
window.open(
|
||||
`https://payment.fossorial.io/buy/2b881c36-ea5d-4c11-8652-9be6810a054f?quantity=${siteCount}`,
|
||||
"_blank"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const totalCost =
|
||||
mode === "license"
|
||||
? licenseFlatRate + siteCount * pricePerSite
|
||||
: siteCount * pricePerSite;
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Credenza open={isOpen} onOpenChange={onOpenChange}>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{mode === "license"
|
||||
? t('licensePurchase')
|
||||
: t('licensePurchaseSites')}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('licensePurchaseDescription', {selectedMode: mode})}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
{t('numberOfSites')}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={decrementSites}
|
||||
disabled={siteCount <= 1}
|
||||
aria-label={t('sitestCountDecrease')}
|
||||
>
|
||||
<MinusCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
<span className="text-3xl w-12 text-center">
|
||||
{siteCount}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={incrementSites}
|
||||
aria-label={t('sitestCountIncrease')}
|
||||
>
|
||||
<PlusCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-muted-foreground text-sm mt-2 text-center">
|
||||
{t('licensePricingPage')}
|
||||
<a
|
||||
href="https://docs.fossorial.io/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{t('pricingPage')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t('cancel')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button onClick={continueToPayment}>
|
||||
{t('pricingPortal')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
43
src/components/SitesDataTable.tsx
Normal file
43
src/components/SitesDataTable.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
createSite?: () => void;
|
||||
onRefresh?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
}
|
||||
|
||||
export function SitesDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
createSite,
|
||||
onRefresh,
|
||||
isRefreshing
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="sites-table"
|
||||
title={t('sites')}
|
||||
searchPlaceholder={t('searchSitesProgress')}
|
||||
searchColumn="name"
|
||||
onAdd={createSite}
|
||||
addButtonText={t('siteAdd')}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
defaultSort={{
|
||||
id: "name",
|
||||
desc: false
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
104
src/components/SitesSplashCard.tsx
Normal file
104
src/components/SitesSplashCard.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowRight, DockIcon as Docker, Globe, Server, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export const SitesSplashCard = () => {
|
||||
const [isDismissed, setIsDismissed] = useState(true);
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const key = "sites-splash-card-dismissed";
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
const dismissed = localStorage.getItem(key);
|
||||
if (dismissed === "true") {
|
||||
setIsDismissed(true);
|
||||
} else {
|
||||
setIsDismissed(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsDismissed(true);
|
||||
localStorage.setItem(key, "true");
|
||||
};
|
||||
|
||||
if (isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full mx-auto overflow-hidden mb-8 hidden md:block relative">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-2 right-2 p-2"
|
||||
aria-label={t('dismiss')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<CardContent className="grid gap-6 p-6 sm:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Globe className="text-blue-500" />
|
||||
Newt ({t('recommended')})
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
{t('siteNewtDescription')}
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li className="flex items-center gap-2">
|
||||
<Server className="text-green-500 w-4 h-4" />
|
||||
{t('siteRunsInDocker')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Server className="text-green-500 w-4 h-4" />
|
||||
{t('siteRunsInShell')}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="mt-4">
|
||||
<Link
|
||||
href="https://docs.digpangolin.com/manage/sites/install-site"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
className="w-full flex items-center"
|
||||
variant="secondary"
|
||||
>
|
||||
{t('siteInstallNewt')}{" "}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
{t('siteWg')}
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
{t('siteWgAnyClients')}
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li className="flex items-center gap-2">
|
||||
<Docker className="text-purple-500 w-4 h-4" />
|
||||
{t('siteWgCompatibleAllClients')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Server className="text-purple-500 w-4 h-4" />
|
||||
{t('siteWgManualConfigurationRequired')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SitesSplashCard;
|
||||
389
src/components/SitesTable.tsx
Normal file
389
src/components/SitesTable.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
"use client";
|
||||
|
||||
import { Column, ColumnDef } from "@tanstack/react-table";
|
||||
import { SitesDataTable } from "@app/components/SitesDataTable";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
MoreHorizontal,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useState, useEffect } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { parseDataSize } from "@app/lib/dataSize";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
|
||||
export type SiteRow = {
|
||||
id: number;
|
||||
nice: string;
|
||||
name: string;
|
||||
mbIn: string;
|
||||
mbOut: string;
|
||||
orgId: string;
|
||||
type: "newt" | "wireguard";
|
||||
newtVersion?: string;
|
||||
newtUpdateAvailable?: boolean;
|
||||
online: boolean;
|
||||
address?: string;
|
||||
};
|
||||
|
||||
type SitesTableProps = {
|
||||
sites: SiteRow[];
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
||||
const [rows, setRows] = useState<SiteRow[]>(sites);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
// Update local state when props change (e.g., after refresh)
|
||||
useEffect(() => {
|
||||
setRows(sites);
|
||||
}, [sites]);
|
||||
|
||||
const refreshData = async () => {
|
||||
console.log("Data refreshed");
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSite = (siteId: number) => {
|
||||
api.delete(`/site/${siteId}`)
|
||||
.catch((e) => {
|
||||
console.error(t("siteErrorDelete"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("siteErrorDelete"),
|
||||
description: formatAxiosError(e, t("siteErrorDelete"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
|
||||
const newRows = rows.filter((row) => row.id !== siteId);
|
||||
|
||||
setRows(newRows);
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnDef<SiteRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "online",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("online")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
if (
|
||||
originalRow.type == "newt" ||
|
||||
originalRow.type == "wireguard"
|
||||
) {
|
||||
if (originalRow.online) {
|
||||
return (
|
||||
<span className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>{t("online")}</span>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<span>{t("offline")}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return <span>-</span>;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "nice",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
className="hidden md:flex whitespace-nowrap"
|
||||
>
|
||||
{t("site")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="hidden md:block whitespace-nowrap">
|
||||
{row.original.nice}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "mbIn",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("dataIn")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
sortingFn: (rowA, rowB) =>
|
||||
parseDataSize(rowA.original.mbIn) -
|
||||
parseDataSize(rowB.original.mbIn)
|
||||
},
|
||||
{
|
||||
accessorKey: "mbOut",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("dataOut")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
sortingFn: (rowA, rowB) =>
|
||||
parseDataSize(rowA.original.mbOut) -
|
||||
parseDataSize(rowB.original.mbOut)
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("connectionType")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
if (originalRow.type === "newt") {
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Badge variant="secondary">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Newt</span>
|
||||
{originalRow.newtVersion && (
|
||||
<span className="text-xs text-gray-500">
|
||||
v{originalRow.newtVersion}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
{originalRow.newtUpdateAvailable && (
|
||||
<InfoPopup
|
||||
info={t("newtUpdateAvailableInfo")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (originalRow.type === "wireguard") {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>WireGuard</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (originalRow.type === "local") {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>{t("local")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
...(env.flags.enableClients ? [{
|
||||
accessorKey: "address",
|
||||
header: ({ column }: { column: Column<SiteRow, unknown> }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Address
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}] : []),
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const siteRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{t("viewSettings")}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedSite(siteRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Link
|
||||
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
||||
>
|
||||
<Button variant={"secondary"} size="sm">
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedSite && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedSite(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
{t("siteQuestionRemove", {
|
||||
selectedSite:
|
||||
selectedSite?.name || selectedSite?.id
|
||||
})}
|
||||
</p>
|
||||
|
||||
<p>{t("siteMessageRemove")}</p>
|
||||
|
||||
<p>{t("siteMessageConfirm")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("siteConfirmDelete")}
|
||||
onConfirm={async () => deleteSite(selectedSite!.id)}
|
||||
string={selectedSite.name}
|
||||
title={t("siteDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SitesDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
createSite={() =>
|
||||
router.push(`/${orgId}/settings/sites/create`)
|
||||
}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
35
src/components/UsersDataTable.tsx
Normal file
35
src/components/UsersDataTable.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
inviteUser?: () => void;
|
||||
}
|
||||
|
||||
export function UsersDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
inviteUser
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="users-table"
|
||||
title={t('users')}
|
||||
searchPlaceholder={t('accessUsersSearch')}
|
||||
searchColumn="email"
|
||||
onAdd={inviteUser}
|
||||
addButtonText={t('accessUserCreate')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
284
src/components/UsersTable.tsx
Normal file
284
src/components/UsersTable.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
|
||||
import { UsersDataTable } from "@app/components/UsersDataTable";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type UserRow = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
displayUsername: string | null;
|
||||
username: string;
|
||||
name: string | null;
|
||||
idpId: number | null;
|
||||
idpName: string;
|
||||
type: string;
|
||||
status: string;
|
||||
role: string;
|
||||
isOwner: boolean;
|
||||
};
|
||||
|
||||
type UsersTableProps = {
|
||||
users: UserRow[];
|
||||
};
|
||||
|
||||
export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<UserRow | null>(null);
|
||||
const [users, setUsers] = useState<UserRow[]>(u);
|
||||
const router = useRouter();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { user, updateUser } = useUserContext();
|
||||
const { org } = useOrgContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const columns: ColumnDef<UserRow>[] = [
|
||||
{
|
||||
accessorKey: "displayUsername",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("username")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "idpName",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("identityProvider")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("role")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const userRow = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{userRow.isOwner && (
|
||||
<Crown className="w-4 h-4 text-yellow-600" />
|
||||
)}
|
||||
<span>{userRow.role}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const userRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<>
|
||||
<div>
|
||||
{userRow.isOwner && (
|
||||
<MoreHorizontal className="h-4 w-4 opacity-0" />
|
||||
)}
|
||||
{!userRow.isOwner && (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<Link
|
||||
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
||||
className="block w-full"
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{t("accessUsersManage")}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
{`${userRow.username}-${userRow.idpId}` !==
|
||||
`${user?.username}-${userRow.idpId}` && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(
|
||||
true
|
||||
);
|
||||
setSelectedUser(
|
||||
userRow
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t(
|
||||
"accessUserRemove"
|
||||
)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
{userRow.isOwner && (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
className="ml-2"
|
||||
size="sm"
|
||||
disabled={true}
|
||||
>
|
||||
{t("manage")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{!userRow.isOwner && (
|
||||
<Link
|
||||
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
|
||||
>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
className="ml-2"
|
||||
size="sm"
|
||||
disabled={userRow.isOwner}
|
||||
>
|
||||
{t("manage")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
async function removeUser() {
|
||||
if (selectedUser) {
|
||||
const res = await api
|
||||
.delete(`/org/${org!.org.orgId}/user/${selectedUser.id}`)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("userErrorOrgRemove"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("userErrorOrgRemoveDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t("userOrgRemoved"),
|
||||
description: t("userOrgRemovedDescription", {
|
||||
email: selectedUser.email || ""
|
||||
})
|
||||
});
|
||||
|
||||
setUsers((prev) =>
|
||||
prev.filter((u) => u.id !== selectedUser?.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
setIsDeleteModalOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedUser(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
{t("userQuestionOrgRemove", {
|
||||
email:
|
||||
selectedUser?.email ||
|
||||
selectedUser?.name ||
|
||||
selectedUser?.username ||
|
||||
""
|
||||
})}
|
||||
</p>
|
||||
|
||||
<p>{t("userMessageOrgRemove")}</p>
|
||||
|
||||
<p>{t("userMessageOrgConfirm")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("userRemoveOrgConfirm")}
|
||||
onConfirm={removeUser}
|
||||
string={
|
||||
selectedUser?.email ||
|
||||
selectedUser?.name ||
|
||||
selectedUser?.username ||
|
||||
""
|
||||
}
|
||||
title={t("userRemoveOrg")}
|
||||
/>
|
||||
|
||||
<UsersDataTable
|
||||
columns={columns}
|
||||
data={users}
|
||||
inviteUser={() => {
|
||||
router.push(
|
||||
`/${org?.org.orgId}/settings/access/users/create`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
126
src/components/ValidateOidcToken.tsx
Normal file
126
src/components/ValidateOidcToken.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { ValidateOidcUrlCallbackResponse } from "@server/routers/idp";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardDescription
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type ValidateOidcTokenParams = {
|
||||
orgId: string;
|
||||
idpId: string;
|
||||
code: string | undefined;
|
||||
expectedState: string | undefined;
|
||||
stateCookie: string | undefined;
|
||||
idp: { name: string };
|
||||
};
|
||||
|
||||
export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { licenseStatus, isLicenseViolation } = useLicenseStatusContext();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
async function validate() {
|
||||
setLoading(true);
|
||||
|
||||
console.log(t('idpOidcTokenValidating'), {
|
||||
code: props.code,
|
||||
expectedState: props.expectedState,
|
||||
stateCookie: props.stateCookie
|
||||
});
|
||||
|
||||
if (isLicenseViolation()) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<ValidateOidcUrlCallbackResponse>
|
||||
>(`/auth/idp/${props.idpId}/oidc/validate-callback`, {
|
||||
code: props.code,
|
||||
state: props.expectedState,
|
||||
storedState: props.stateCookie
|
||||
});
|
||||
|
||||
console.log(t('idpOidcTokenResponse'), res.data);
|
||||
|
||||
const redirectUrl = res.data.data.redirectUrl;
|
||||
|
||||
if (!redirectUrl) {
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
if (redirectUrl.startsWith("http")) {
|
||||
window.location.href = res.data.data.redirectUrl; // this is validated by the parent using this component
|
||||
} else {
|
||||
router.push(res.data.data.redirectUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(formatAxiosError(e, t('idpErrorOidcTokenValidating')));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
validate();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('idpConnectingTo', {name: props.idp.name})}</CardTitle>
|
||||
<CardDescription>{t('idpConnectingToDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center space-y-4">
|
||||
{loading && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>{t('idpConnectingToProcess')}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && (
|
||||
<div className="flex items-center space-x-2 text-green-600">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<span>{t('idpConnectingToFinished')}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="w-full">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<AlertDescription className="flex flex-col space-y-2">
|
||||
<span>
|
||||
{t('idpErrorConnectingTo', {name: props.idp.name})}
|
||||
</span>
|
||||
<span className="text-xs">{error}</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
256
src/components/VerifyEmailForm.tsx
Normal file
256
src/components/VerifyEmailForm.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot
|
||||
} from "@/components/ui/input-otp";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { VerifyEmailResponse } from "@server/routers/auth";
|
||||
import { ArrowRight, IdCard, Loader2 } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "./ui/alert";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type VerifyEmailFormProps = {
|
||||
email: string;
|
||||
redirect?: string;
|
||||
};
|
||||
|
||||
export default function VerifyEmailForm({
|
||||
email,
|
||||
redirect
|
||||
}: VerifyEmailFormProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
function logout() {
|
||||
api.post("/auth/logout")
|
||||
.catch((e) => {
|
||||
console.error(t("logoutError"), e);
|
||||
toast({
|
||||
title: t("logoutError"),
|
||||
description: formatAxiosError(e, t("logoutError"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
router.push("/auth/login");
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
const FormSchema = z.object({
|
||||
email: z.string().email({ message: t("emailInvalid") }),
|
||||
pin: z.string().min(8, {
|
||||
message: t("verificationCodeLengthRequirements")
|
||||
})
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
email: email,
|
||||
pin: ""
|
||||
}
|
||||
});
|
||||
|
||||
async function onSubmit(data: z.infer<typeof FormSchema>) {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const res = await api
|
||||
.post<AxiosResponse<VerifyEmailResponse>>("/auth/verify-email", {
|
||||
code: data.pin
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(formatAxiosError(e, t("errorOccurred")));
|
||||
console.error(t("emailErrorVerify"), e);
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
|
||||
if (res && res.data?.data?.valid) {
|
||||
setError(null);
|
||||
setSuccessMessage(t("emailVerified"));
|
||||
setTimeout(() => {
|
||||
if (redirect) {
|
||||
const safe = cleanRedirect(redirect);
|
||||
router.push(safe);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResendCode() {
|
||||
setIsResending(true);
|
||||
|
||||
const res = await api.post("/auth/verify-email/request").catch((e) => {
|
||||
setError(formatAxiosError(e, t("errorOccurred")));
|
||||
console.error(t("verificationCodeErrorResend"), e);
|
||||
});
|
||||
|
||||
if (res) {
|
||||
setError(null);
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t("verificationCodeResend"),
|
||||
description: t("verificationCodeResendDescription")
|
||||
});
|
||||
}
|
||||
|
||||
setIsResending(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("emailVerify")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("emailVerifyDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-muted-foreground mb-4">
|
||||
{email}
|
||||
</p>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="verify-email-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="pin"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={8}
|
||||
{...field}
|
||||
>
|
||||
<InputOTPGroup className="flex">
|
||||
<InputOTPSlot
|
||||
index={0}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={1}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={2}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={3}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={4}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={5}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={6}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={7}
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={handleResendCode}
|
||||
disabled={isResending}
|
||||
>
|
||||
{isResending
|
||||
? t("emailVerifyResendProgress")
|
||||
: t("emailVerifyResend")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<Alert variant="success">
|
||||
<AlertDescription>
|
||||
{successMessage}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting}
|
||||
form="verify-email-form"
|
||||
>
|
||||
{isSubmitting && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{t("submit")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant={"secondary"}
|
||||
className="w-full"
|
||||
onClick={logout}
|
||||
>
|
||||
Log in with another account
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user