mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-02 16:19:08 +00:00
Merge branch 'dev' into clients-pops
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
} 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;
|
||||
@@ -38,19 +39,21 @@ export default function OrganizationLandingCard(
|
||||
) {
|
||||
const [orgData] = useState(props);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const orgStats: OrgStat[] = [
|
||||
{
|
||||
label: "Sites",
|
||||
label: t('sites'),
|
||||
value: orgData.overview.stats.sites,
|
||||
icon: <Combine className="h-6 w-6" />
|
||||
},
|
||||
{
|
||||
label: "Resources",
|
||||
label: t('resources'),
|
||||
value: orgData.overview.stats.resources,
|
||||
icon: <Waypoints className="h-6 w-6" />
|
||||
},
|
||||
{
|
||||
label: "Users",
|
||||
label: t('users'),
|
||||
value: orgData.overview.stats.users,
|
||||
icon: <Users className="h-6 w-6" />
|
||||
}
|
||||
@@ -81,9 +84,9 @@ export default function OrganizationLandingCard(
|
||||
))}
|
||||
</div>
|
||||
<div className="text-center text-lg">
|
||||
Your role:{" "}
|
||||
{t('accessRoleYour')}{" "}
|
||||
<span className="font-semibold">
|
||||
{orgData.overview.isOwner ? "Owner" : orgData.overview.userRole}
|
||||
{orgData.overview.isOwner ? t('accessRoleOwner') : orgData.overview.userRole}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -92,7 +95,7 @@ export default function OrganizationLandingCard(
|
||||
<Link href={`/${orgData.overview.orgId}/settings`}>
|
||||
<Button size="lg" className="w-full md:w-auto">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Organization Settings
|
||||
{t('orgGeneralSettings')}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardFooter>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface AccessPageHeaderAndNavProps {
|
||||
children: React.ReactNode;
|
||||
@@ -12,20 +13,22 @@ export default function AccessPageHeaderAndNav({
|
||||
children,
|
||||
hasInvitations
|
||||
}: AccessPageHeaderAndNavProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: "Users",
|
||||
title: t('users'),
|
||||
href: `/{orgId}/settings/access/users`
|
||||
},
|
||||
{
|
||||
title: "Roles",
|
||||
title: t('roles'),
|
||||
href: `/{orgId}/settings/access/roles`
|
||||
}
|
||||
];
|
||||
|
||||
if (hasInvitations) {
|
||||
navItems.push({
|
||||
title: "Invitations",
|
||||
title: t('invite'),
|
||||
href: `/{orgId}/settings/access/invitations`
|
||||
});
|
||||
}
|
||||
@@ -33,8 +36,8 @@ export default function AccessPageHeaderAndNav({
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage Users & Roles"
|
||||
description="Invite users and add them to roles to manage access to your organization"
|
||||
title={t('accessUsersRoles')}
|
||||
description={t('accessUsersRolesDescription')}
|
||||
/>
|
||||
|
||||
<HorizontalTabs items={navItems}>
|
||||
|
||||
@@ -4,6 +4,7 @@ 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>[];
|
||||
@@ -14,12 +15,15 @@ export function InvitationsDataTable<TData, TValue>({
|
||||
columns,
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="Invitations"
|
||||
searchPlaceholder="Search invitations..."
|
||||
title={t('invite')}
|
||||
searchPlaceholder={t('inviteSearch')}
|
||||
searchColumn="email"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ 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";
|
||||
|
||||
export type InvitationRow = {
|
||||
id: string;
|
||||
@@ -39,6 +40,8 @@ export default function InvitationsTable({
|
||||
const [selectedInvitation, setSelectedInvitation] =
|
||||
useState<InvitationRow | null>(null);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { org } = useOrgContext();
|
||||
|
||||
@@ -51,7 +54,7 @@ export default function InvitationsTable({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<span className="sr-only">{t('openMenu')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -62,7 +65,7 @@ export default function InvitationsTable({
|
||||
setSelectedInvitation(invitation);
|
||||
}}
|
||||
>
|
||||
<span>Regenerate Invitation</span>
|
||||
<span>{t('inviteRegenerate')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -71,7 +74,7 @@ export default function InvitationsTable({
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
Remove Invitation
|
||||
{t('inviteRemove')}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -81,11 +84,11 @@ export default function InvitationsTable({
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: "Email"
|
||||
header: t('email')
|
||||
},
|
||||
{
|
||||
accessorKey: "expiresAt",
|
||||
header: "Expires At",
|
||||
header: t('expiresAt'),
|
||||
cell: ({ row }) => {
|
||||
const expiresAt = new Date(row.original.expiresAt);
|
||||
const isExpired = expiresAt < new Date();
|
||||
@@ -99,7 +102,7 @@ export default function InvitationsTable({
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
header: "Role"
|
||||
header: t('role')
|
||||
}
|
||||
];
|
||||
|
||||
@@ -112,17 +115,16 @@ export default function InvitationsTable({
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to remove invitation",
|
||||
description:
|
||||
"An error occurred while removing the invitation."
|
||||
title: t('inviteRemoveError'),
|
||||
description: t('inviteRemoveErrorDescription')
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "Invitation removed",
|
||||
description: `The invitation for ${selectedInvitation.email} has been removed.`
|
||||
title: t('inviteRemoved'),
|
||||
description: t('inviteRemovedDescription', {email: selectedInvitation.email})
|
||||
});
|
||||
|
||||
setInvitations((prev) =>
|
||||
@@ -146,23 +148,20 @@ export default function InvitationsTable({
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to remove the invitation for{" "}
|
||||
<b>{selectedInvitation?.email}</b>?
|
||||
{t('inviteQuestionRemove', {email: selectedInvitation?.email || ""})}
|
||||
</p>
|
||||
<p>
|
||||
Once removed, this invitation will no longer be
|
||||
valid. You can always re-invite the user later.
|
||||
{t('inviteMessageRemove')}
|
||||
</p>
|
||||
<p>
|
||||
To confirm, please type the email address of the
|
||||
invitation below.
|
||||
{t('inviteMessageConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Remove Invitation"
|
||||
buttonText={t('inviteRemoveConfirm')}
|
||||
onConfirm={removeInvitation}
|
||||
string={selectedInvitation?.email ?? ""}
|
||||
title="Remove Invitation"
|
||||
title={t('inviteRemove')}
|
||||
/>
|
||||
<RegenerateInvitationForm
|
||||
open={isRegenerateModalOpen}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type RegenerateInvitationFormProps = {
|
||||
open: boolean;
|
||||
@@ -56,14 +57,16 @@ export default function RegenerateInvitationForm({
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const validForOptions = [
|
||||
{ hours: 24, name: "1 day" },
|
||||
{ hours: 48, name: "2 days" },
|
||||
{ hours: 72, name: "3 days" },
|
||||
{ hours: 96, name: "4 days" },
|
||||
{ hours: 120, name: "5 days" },
|
||||
{ hours: 144, name: "6 days" },
|
||||
{ hours: 168, name: "7 days" }
|
||||
{ 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(() => {
|
||||
@@ -79,9 +82,8 @@ export default function RegenerateInvitationForm({
|
||||
if (!org?.org.orgId) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Organization ID Missing",
|
||||
description:
|
||||
"Unable to regenerate invitation without an organization ID.",
|
||||
title: t('orgMissing'),
|
||||
description: t('orgMissingMessage'),
|
||||
duration: 5000
|
||||
});
|
||||
return;
|
||||
@@ -105,15 +107,15 @@ export default function RegenerateInvitationForm({
|
||||
if (sendEmail) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "Invitation Regenerated",
|
||||
description: `A new invitation has been sent to ${invitation.email}.`,
|
||||
title: t('inviteRegenerated'),
|
||||
description: t('inviteSent', {email: invitation.email}),
|
||||
duration: 5000
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "Invitation Regenerated",
|
||||
description: `A new invitation has been generated for ${invitation.email}.`,
|
||||
title: t('inviteRegenerated'),
|
||||
description: t('inviteGenerate', {email: invitation.email}),
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
@@ -130,24 +132,22 @@ export default function RegenerateInvitationForm({
|
||||
if (error.response?.status === 409) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Duplicate Invite",
|
||||
description: "An invitation for this user already exists.",
|
||||
title: t('inviteDuplicateError'),
|
||||
description: t('inviteDuplicateErrorDescription'),
|
||||
duration: 5000
|
||||
});
|
||||
} else if (error.response?.status === 429) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Rate Limit Exceeded",
|
||||
description:
|
||||
"You have exceeded the limit of 3 regenerations per hour. Please try again later.",
|
||||
title: t('inviteRateLimitError'),
|
||||
description: t('inviteRateLimitErrorDescription'),
|
||||
duration: 5000
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to Regenerate Invitation",
|
||||
description:
|
||||
"An error occurred while regenerating the invitation.",
|
||||
title: t('inviteRegenerateError'),
|
||||
description: t('inviteRegenerateErrorDescription'),
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
@@ -168,18 +168,16 @@ export default function RegenerateInvitationForm({
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Regenerate Invitation</CredenzaTitle>
|
||||
<CredenzaTitle>{t('inviteRegenerate')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Revoke previous invitation and create a new one
|
||||
{t('inviteRegenerateDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
{!inviteLink ? (
|
||||
<div>
|
||||
<p>
|
||||
Are you sure you want to regenerate the
|
||||
invitation for <b>{invitation?.email}</b>? This
|
||||
will revoke the previous invitation.
|
||||
{t('inviteQuestionRegenerate', {email: invitation?.email || ""})}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2 mt-4">
|
||||
<Checkbox
|
||||
@@ -190,12 +188,12 @@ export default function RegenerateInvitationForm({
|
||||
}
|
||||
/>
|
||||
<label htmlFor="send-email">
|
||||
Send email notification to the user
|
||||
{t('inviteSentEmail')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label>
|
||||
Validity Period
|
||||
{t('inviteValidityPeriod')}
|
||||
</Label>
|
||||
<Select
|
||||
value={validHours.toString()}
|
||||
@@ -204,7 +202,7 @@ export default function RegenerateInvitationForm({
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select validity period" />
|
||||
<SelectValue placeholder={t('inviteValidityPeriodSelect')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{validForOptions.map((option) => (
|
||||
@@ -222,9 +220,7 @@ export default function RegenerateInvitationForm({
|
||||
) : (
|
||||
<div className="space-y-4 max-w-md">
|
||||
<p>
|
||||
The invitation has been regenerated. The user
|
||||
must access the link below to accept the
|
||||
invitation.
|
||||
{t('inviteRegenerateMessage')}
|
||||
</p>
|
||||
<CopyTextBox text={inviteLink} wrapText={false} />
|
||||
</div>
|
||||
@@ -234,18 +230,18 @@ export default function RegenerateInvitationForm({
|
||||
{!inviteLink ? (
|
||||
<>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button variant="outline">{t('cancel')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
onClick={handleRegenerate}
|
||||
loading={loading}
|
||||
>
|
||||
Regenerate
|
||||
{t('inviteRegenerateButton')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
|
||||
@@ -9,6 +9,7 @@ import UserProvider from "@app/providers/UserProvider";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
type InvitationsPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
@@ -18,6 +19,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function InvitationsPage(props: InvitationsPageProps) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
@@ -66,7 +68,7 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
|
||||
id: invite.inviteId,
|
||||
email: invite.email,
|
||||
expiresAt: new Date(Number(invite.expiresAt)).toISOString(),
|
||||
role: invite.roleName || "Unknown Role",
|
||||
role: invite.roleName || t('accessRoleUnknown'),
|
||||
roleId: invite.roleId
|
||||
};
|
||||
});
|
||||
@@ -74,8 +76,8 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Open Invitations"
|
||||
description="Manage your invitations to other users"
|
||||
title={t('inviteTitle')}
|
||||
description={t('inviteDescription')}
|
||||
/>
|
||||
<UserProvider user={user!}>
|
||||
<OrgProvider org={org}>
|
||||
|
||||
@@ -31,6 +31,7 @@ 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;
|
||||
@@ -38,17 +39,18 @@ type CreateRoleFormProps = {
|
||||
afterCreate?: (res: CreateRoleResponse) => Promise<void>;
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string({ message: "Name is required" }).max(32),
|
||||
description: z.string().max(255).optional()
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -76,10 +78,10 @@ export default function CreateRoleForm({
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to create role",
|
||||
title: t('accessRoleErrorCreate'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while creating the role."
|
||||
t('accessRoleErrorCreateDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -87,8 +89,8 @@ export default function CreateRoleForm({
|
||||
if (res && res.status === 201) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "Role created",
|
||||
description: "The role has been successfully created."
|
||||
title: t('accessRoleCreated'),
|
||||
description: t('accessRoleCreatedDescription')
|
||||
});
|
||||
|
||||
if (open) {
|
||||
@@ -115,10 +117,9 @@ export default function CreateRoleForm({
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Create Role</CredenzaTitle>
|
||||
<CredenzaTitle>{t('accessRoleCreate')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Create a new role to group users and manage their
|
||||
permissions.
|
||||
{t('accessRoleCreateDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
@@ -133,7 +134,7 @@ export default function CreateRoleForm({
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role Name</FormLabel>
|
||||
<FormLabel>{t('accessRoleName')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
@@ -146,7 +147,7 @@ export default function CreateRoleForm({
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormLabel>{t('description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
@@ -159,7 +160,7 @@ export default function CreateRoleForm({
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -167,7 +168,7 @@ export default function CreateRoleForm({
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Create Role
|
||||
{t('accessRoleCreateSubmit')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
||||
@@ -38,6 +38,7 @@ import { RoleRow } from "./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;
|
||||
@@ -46,10 +47,6 @@ type CreateRoleFormProps = {
|
||||
afterDelete?: () => void;
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
newRoleId: z.string({ message: "New role is required" })
|
||||
});
|
||||
|
||||
export default function DeleteRoleForm({
|
||||
open,
|
||||
roleToDelete,
|
||||
@@ -57,12 +54,17 @@ export default function DeleteRoleForm({
|
||||
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
|
||||
@@ -73,10 +75,10 @@ export default function DeleteRoleForm({
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch roles",
|
||||
title: t('accessRoleErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while fetching the roles"
|
||||
t('accessRoleErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -112,10 +114,10 @@ export default function DeleteRoleForm({
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to remove role",
|
||||
title: t('accessRoleErrorRemove'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while removing the role."
|
||||
t('accessRoleErrorRemoveDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -123,8 +125,8 @@ export default function DeleteRoleForm({
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "Role removed",
|
||||
description: "The role has been successfully removed."
|
||||
title: t('accessRoleRemoved'),
|
||||
description: t('accessRoleRemovedDescription')
|
||||
});
|
||||
|
||||
if (open) {
|
||||
@@ -151,22 +153,19 @@ export default function DeleteRoleForm({
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Remove Role</CredenzaTitle>
|
||||
<CredenzaTitle>{t('accessRoleRemove')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Remove a role from the organization
|
||||
{t('accessRoleRemoveDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
You're about to delete the{" "}
|
||||
<b>{roleToDelete.name}</b> role. You cannot
|
||||
undo this action.
|
||||
{t('accessRoleQuestionRemove', {name: roleToDelete.name})}
|
||||
</p>
|
||||
<p>
|
||||
Before deleting this role, please select a
|
||||
new role to transfer existing members to.
|
||||
{t('accessRoleRequiredRemove')}
|
||||
</p>
|
||||
</div>
|
||||
<Form {...form}>
|
||||
@@ -180,7 +179,7 @@ export default function DeleteRoleForm({
|
||||
name="newRoleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<FormLabel>{t('role')}</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
@@ -189,7 +188,7 @@ export default function DeleteRoleForm({
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
<SelectValue placeholder={t('accessRoleSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -215,7 +214,7 @@ export default function DeleteRoleForm({
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -223,7 +222,7 @@ export default function DeleteRoleForm({
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Remove Role
|
||||
{t('accessRoleRemoveSubmit')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
||||
@@ -4,6 +4,7 @@ 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>[];
|
||||
@@ -16,15 +17,18 @@ export function RolesDataTable<TData, TValue>({
|
||||
data,
|
||||
createRole
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="Roles"
|
||||
searchPlaceholder="Search roles..."
|
||||
title={t('roles')}
|
||||
searchPlaceholder={t('accessRolesSearch')}
|
||||
searchColumn="name"
|
||||
onAdd={createRole}
|
||||
addButtonText="Add Role"
|
||||
addButtonText={t('accessRolesAdd')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import CreateRoleForm from "./CreateRoleForm";
|
||||
import DeleteRoleForm from "./DeleteRoleForm";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export type RoleRow = Role;
|
||||
|
||||
@@ -38,6 +39,8 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
||||
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const columns: ColumnDef<RoleRow>[] = [
|
||||
{
|
||||
id: "actions",
|
||||
@@ -58,7 +61,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Open menu
|
||||
{t('openMenu')}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -71,7 +74,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
Delete Role
|
||||
{t('accessRoleDelete')}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -92,7 +95,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
{t('name')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -100,7 +103,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Description"
|
||||
header: t('description')
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import RolesTable, { RoleRow } from "./RolesTable";
|
||||
import { SidebarSettings } from "@app/components/SidebarSettings";
|
||||
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
type RolesPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
@@ -62,12 +63,13 @@ export default async function RolesPage(props: RolesPageProps) {
|
||||
}
|
||||
|
||||
const roleRows: RoleRow[] = roles;
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage Roles"
|
||||
description="Configure roles to manage access to your organization"
|
||||
title={t('accessRolesManage')}
|
||||
description={t('accessRolesDescription')}
|
||||
/>
|
||||
<OrgProvider org={org}>
|
||||
<RolesTable roles={roleRows} />
|
||||
|
||||
@@ -4,6 +4,7 @@ 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>[];
|
||||
@@ -16,15 +17,18 @@ export function UsersDataTable<TData, TValue>({
|
||||
data,
|
||||
inviteUser
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="Users"
|
||||
searchPlaceholder="Search users..."
|
||||
title={t('users')}
|
||||
searchPlaceholder={t('accessUsersSearch')}
|
||||
searchColumn="email"
|
||||
onAdd={inviteUser}
|
||||
addButtonText="Create User"
|
||||
addButtonText={t('accessUserCreate')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ 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;
|
||||
@@ -47,6 +48,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { user, updateUser } = useUserContext();
|
||||
const { org } = useOrgContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const columns: ColumnDef<UserRow>[] = [
|
||||
{
|
||||
@@ -68,7 +70,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Open menu
|
||||
{t('openMenu')}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -79,7 +81,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
className="block w-full"
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
Manage User
|
||||
{t('accessUsersManage')}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
{`${userRow.username}-${userRow.idpId}` !==
|
||||
@@ -95,7 +97,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
Remove User
|
||||
{t('accessUserRemove')}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
@@ -118,7 +120,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Username
|
||||
{t('username')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -134,7 +136,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Identity Provider
|
||||
{t('identityProvider')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -150,7 +152,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Role
|
||||
{t('role')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -179,7 +181,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
variant="ghost"
|
||||
className="opacity-0 cursor-default"
|
||||
>
|
||||
Placeholder
|
||||
{t('placeholder')}
|
||||
</Button>
|
||||
)}
|
||||
{!userRow.isOwner && (
|
||||
@@ -190,7 +192,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
variant={"outlinePrimary"}
|
||||
className="ml-2"
|
||||
>
|
||||
Manage
|
||||
{t('manage')}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -208,10 +210,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to remove user",
|
||||
title: t('userErrorOrgRemove'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while removing the user."
|
||||
t('userErrorOrgRemoveDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -219,8 +221,8 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "User removed",
|
||||
description: `The user ${selectedUser.email} has been removed from the organization.`
|
||||
title: t('userOrgRemoved'),
|
||||
description: t('userOrgRemovedDescription', {email: selectedUser.email || ""})
|
||||
});
|
||||
|
||||
setUsers((prev) =>
|
||||
@@ -242,29 +244,19 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to remove{" "}
|
||||
<b>
|
||||
{selectedUser?.email ||
|
||||
selectedUser?.name ||
|
||||
selectedUser?.username}
|
||||
</b>{" "}
|
||||
from the organization?
|
||||
{t('userQuestionOrgRemove', {email: selectedUser?.email || selectedUser?.name || selectedUser?.username || ""})}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Once removed, this user will no longer have access
|
||||
to the organization. You can always re-invite them
|
||||
later, but they will need to accept the invitation
|
||||
again.
|
||||
{t('userMessageOrgRemove')}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the name of the of the user
|
||||
below.
|
||||
{t('userMessageOrgConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Remove User"
|
||||
buttonText={t('userRemoveOrgConfirm')}
|
||||
onConfirm={removeUser}
|
||||
string={
|
||||
selectedUser?.email ||
|
||||
@@ -272,7 +264,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
selectedUser?.username ||
|
||||
""
|
||||
}
|
||||
title="Remove User from Organization"
|
||||
title={t('userRemoveOrg')}
|
||||
/>
|
||||
|
||||
<UsersDataTable
|
||||
|
||||
@@ -40,11 +40,7 @@ import {
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
const formSchema = z.object({
|
||||
username: z.string(),
|
||||
roleId: z.string().min(1, { message: "Please select a role" })
|
||||
});
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function AccessControlsPage() {
|
||||
const { orgUser: user } = userOrgUserContext();
|
||||
@@ -56,6 +52,13 @@ export default function AccessControlsPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const formSchema = z.object({
|
||||
username: z.string(),
|
||||
roleId: z.string().min(1, { message: t('accessRoleSelectPlease') })
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
@@ -72,10 +75,10 @@ export default function AccessControlsPage() {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch roles",
|
||||
title: t('accessRoleErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while fetching the roles"
|
||||
t('accessRoleErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -100,10 +103,10 @@ export default function AccessControlsPage() {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to add user to role",
|
||||
title: t('accessRoleErrorAdd'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while adding user to the role."
|
||||
t('accessRoleErrorAddDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -111,8 +114,8 @@ export default function AccessControlsPage() {
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "User saved",
|
||||
description: "The user has been updated."
|
||||
title: t('userSaved'),
|
||||
description: t('userSavedDescription')
|
||||
});
|
||||
}
|
||||
|
||||
@@ -123,10 +126,9 @@ export default function AccessControlsPage() {
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>Access Controls</SettingsSectionTitle>
|
||||
<SettingsSectionTitle>{t('accessControls')}</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Manage what this user can access and do in the
|
||||
organization
|
||||
{t('accessControlsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
@@ -143,14 +145,14 @@ export default function AccessControlsPage() {
|
||||
name="roleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<FormLabel>{t('role')}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
<SelectValue placeholder={t('accessRoleSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -180,7 +182,7 @@ export default function AccessControlsPage() {
|
||||
disabled={loading}
|
||||
form="access-controls-form"
|
||||
>
|
||||
Save Access Controls
|
||||
{t('accessControlsSubmit')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import Link from "next/link";
|
||||
import { cache } from "react";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
interface UserLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -26,6 +27,8 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
|
||||
|
||||
const { children } = props;
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
let user = null;
|
||||
try {
|
||||
const getOrgUser = cache(async () =>
|
||||
@@ -42,7 +45,7 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: "Access Controls",
|
||||
title: t('accessControls'),
|
||||
href: "/{orgId}/settings/access/users/{userId}/access-controls"
|
||||
}
|
||||
];
|
||||
@@ -51,7 +54,7 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={`${user?.email}`}
|
||||
description="Manage the settings on this user"
|
||||
description={t('userDescription2')}
|
||||
/>
|
||||
<OrgUserProvider orgUser={user}>
|
||||
<HorizontalTabs items={navItems}>
|
||||
|
||||
@@ -44,6 +44,7 @@ import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { ListIdpsResponse } from "@server/routers/idp";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type UserType = "internal" | "oidc";
|
||||
|
||||
@@ -59,38 +60,12 @@ interface IdpOption {
|
||||
type: string;
|
||||
}
|
||||
|
||||
const internalFormSchema = z.object({
|
||||
email: z.string().email({ message: "Invalid email address" }),
|
||||
validForHours: z.string().min(1, { message: "Please select a duration" }),
|
||||
roleId: z.string().min(1, { message: "Please select a role" })
|
||||
});
|
||||
|
||||
const externalFormSchema = z.object({
|
||||
username: z.string().min(1, { message: "Username is required" }),
|
||||
email: z
|
||||
.string()
|
||||
.email({ message: "Invalid email address" })
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
name: z.string().optional(),
|
||||
roleId: z.string().min(1, { message: "Please select a role" }),
|
||||
idpId: z.string().min(1, { message: "Please select an identity provider" })
|
||||
});
|
||||
|
||||
const formatIdpType = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case "oidc":
|
||||
return "Generic OAuth2/OIDC provider.";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { orgId } = useParams();
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const t = useTranslations();
|
||||
|
||||
const [userType, setUserType] = useState<UserType | null>("internal");
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
@@ -102,14 +77,41 @@ export default function Page() {
|
||||
const [selectedIdp, setSelectedIdp] = useState<IdpOption | null>(null);
|
||||
const [dataLoaded, setDataLoaded] = useState(false);
|
||||
|
||||
const internalFormSchema = z.object({
|
||||
email: z.string().email({ message: t('emailInvalid') }),
|
||||
validForHours: z.string().min(1, { message: t('inviteValidityDuration') }),
|
||||
roleId: z.string().min(1, { message: t('accessRoleSelectPlease') })
|
||||
});
|
||||
|
||||
const externalFormSchema = z.object({
|
||||
username: z.string().min(1, { message: t('usernameRequired') }),
|
||||
email: z
|
||||
.string()
|
||||
.email({ message: t('emailInvalid') })
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
name: z.string().optional(),
|
||||
roleId: z.string().min(1, { message: t('accessRoleSelectPlease') }),
|
||||
idpId: z.string().min(1, { message: t('idpSelectPlease') })
|
||||
});
|
||||
|
||||
const formatIdpType = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case "oidc":
|
||||
return t('idpGenericOidc');
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
const validFor = [
|
||||
{ hours: 24, name: "1 day" },
|
||||
{ hours: 48, name: "2 days" },
|
||||
{ hours: 72, name: "3 days" },
|
||||
{ hours: 96, name: "4 days" },
|
||||
{ hours: 120, name: "5 days" },
|
||||
{ hours: 144, name: "6 days" },
|
||||
{ hours: 168, name: "7 days" }
|
||||
{ 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}) }
|
||||
];
|
||||
|
||||
const internalForm = useForm<z.infer<typeof internalFormSchema>>({
|
||||
@@ -155,10 +157,10 @@ export default function Page() {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch roles",
|
||||
title: t('accessRoleErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while fetching the roles"
|
||||
t('accessRoleErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -178,10 +180,10 @@ export default function Page() {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch identity providers",
|
||||
title: t('idpErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while fetching identity providers"
|
||||
t('idpErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -218,17 +220,16 @@ export default function Page() {
|
||||
if (e.response?.status === 409) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "User Already Exists",
|
||||
description:
|
||||
"This user is already a member of the organization."
|
||||
title: t('userErrorExists'),
|
||||
description: t('userErrorExistsDescription')
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to invite user",
|
||||
title: t('inviteError'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while inviting the user"
|
||||
t('inviteErrorDescription')
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -238,8 +239,8 @@ export default function Page() {
|
||||
setInviteLink(res.data.data.inviteLink);
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "User invited",
|
||||
description: "The user has been successfully invited."
|
||||
title: t('userInvited'),
|
||||
description: t('userInvitedDescription')
|
||||
});
|
||||
|
||||
setExpiresInDays(parseInt(values.validForHours) / 24);
|
||||
@@ -265,10 +266,10 @@ export default function Page() {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to create user",
|
||||
title: t('userErrorCreate'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while creating the user"
|
||||
t('userErrorCreateDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -276,8 +277,8 @@ export default function Page() {
|
||||
if (res && res.status === 201) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "User created",
|
||||
description: "The user has been successfully created."
|
||||
title: t('userCreated'),
|
||||
description: t('userCreatedDescription')
|
||||
});
|
||||
router.push(`/${orgId}/settings/access/users`);
|
||||
}
|
||||
@@ -288,13 +289,13 @@ export default function Page() {
|
||||
const userTypes: ReadonlyArray<UserTypeOption> = [
|
||||
{
|
||||
id: "internal",
|
||||
title: "Internal User",
|
||||
description: "Invite a user to join your organization directly."
|
||||
title: t('userTypeInternal'),
|
||||
description: t('userTypeInternalDescription')
|
||||
},
|
||||
{
|
||||
id: "oidc",
|
||||
title: "External User",
|
||||
description: "Create a user with an external identity provider."
|
||||
title: t('userTypeExternal'),
|
||||
description: t('userTypeExternalDescription')
|
||||
}
|
||||
];
|
||||
|
||||
@@ -302,8 +303,8 @@ export default function Page() {
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title="Create User"
|
||||
description="Follow the steps below to create a new user"
|
||||
title={t('accessUserCreate')}
|
||||
description={t('accessUserCreateDescription')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -311,7 +312,7 @@ export default function Page() {
|
||||
router.push(`/${orgId}/settings/access/users`);
|
||||
}}
|
||||
>
|
||||
See All Users
|
||||
{t('userSeeAll')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -320,10 +321,10 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
User Type
|
||||
{t('userTypeTitle')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Determine how you want to create the user
|
||||
{t('userTypeDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -349,10 +350,10 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
User Information
|
||||
{t('userSettings')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Enter the details for the new user
|
||||
{t('userSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -373,7 +374,7 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Email
|
||||
{t('email')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -402,8 +403,7 @@ export default function Page() {
|
||||
htmlFor="send-email"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Send invite email to
|
||||
user
|
||||
{t('inviteEmailSent')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
@@ -416,7 +416,7 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Valid For
|
||||
{t('inviteValid')}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
@@ -428,7 +428,7 @@ export default function Page() {
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select duration" />
|
||||
<SelectValue placeholder={t('selectDuration')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -463,7 +463,7 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Role
|
||||
{t('role')}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
@@ -472,7 +472,7 @@ export default function Page() {
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
<SelectValue placeholder={t('accessRoleSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -503,37 +503,16 @@ export default function Page() {
|
||||
<div className="max-w-md space-y-4">
|
||||
{sendEmail && (
|
||||
<p>
|
||||
An email has
|
||||
been sent to the
|
||||
user with the
|
||||
access link
|
||||
below. They must
|
||||
access the link
|
||||
to accept the
|
||||
invitation.
|
||||
{t('inviteEmailSentDescription')}
|
||||
</p>
|
||||
)}
|
||||
{!sendEmail && (
|
||||
<p>
|
||||
The user has
|
||||
been invited.
|
||||
They must access
|
||||
the link below
|
||||
to accept the
|
||||
invitation.
|
||||
{t('inviteSentDescription')}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
The invite will
|
||||
expire in{" "}
|
||||
<b>
|
||||
{expiresInDays}{" "}
|
||||
{expiresInDays ===
|
||||
1
|
||||
? "day"
|
||||
: "days"}
|
||||
</b>
|
||||
.
|
||||
{t('inviteExpiresIn', {days: expiresInDays})}
|
||||
</p>
|
||||
<CopyTextBox
|
||||
text={inviteLink}
|
||||
@@ -554,20 +533,16 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Identity Provider
|
||||
{t('idpTitle')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Select the identity provider for the
|
||||
external user
|
||||
{t('idpSelect')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{idps.length === 0 ? (
|
||||
<p className="text-muted-foreground">
|
||||
No identity providers are
|
||||
configured. Please configure an
|
||||
identity provider before creating
|
||||
external users.
|
||||
{t('idpNotConfigured')}
|
||||
</p>
|
||||
) : (
|
||||
<Form {...externalForm}>
|
||||
@@ -621,10 +596,10 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
User Information
|
||||
{t('userSettings')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Enter the details for the new user
|
||||
{t('userSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -645,7 +620,7 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Username
|
||||
{t('username')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -653,15 +628,7 @@ export default function Page() {
|
||||
/>
|
||||
</FormControl>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
This must
|
||||
match the
|
||||
unique
|
||||
username
|
||||
that exists
|
||||
in the
|
||||
selected
|
||||
identity
|
||||
provider.
|
||||
{t('usernameUniq')}
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -676,8 +643,7 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Email
|
||||
(Optional)
|
||||
{t('emailOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -697,8 +663,7 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name
|
||||
(Optional)
|
||||
{t('nameOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -718,7 +683,7 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Role
|
||||
{t('role')}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
@@ -727,7 +692,7 @@ export default function Page() {
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
<SelectValue placeholder={t('accessRoleSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -771,7 +736,7 @@ export default function Page() {
|
||||
router.push(`/${orgId}/settings/access/users`);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
{userType && dataLoaded && (
|
||||
<Button
|
||||
@@ -783,7 +748,7 @@ export default function Page() {
|
||||
(userType === "internal" && inviteLink !== null)
|
||||
}
|
||||
>
|
||||
Create User
|
||||
{t('accessUserCreate')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import UserProvider from "@app/providers/UserProvider";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
type UsersPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
@@ -22,6 +23,7 @@ export default async function UsersPage(props: UsersPageProps) {
|
||||
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
const t = await getTranslations();
|
||||
|
||||
let users: ListUsersResponse["users"] = [];
|
||||
let hasInvitations = false;
|
||||
@@ -76,9 +78,9 @@ export default async function UsersPage(props: UsersPageProps) {
|
||||
email: user.email,
|
||||
type: user.type,
|
||||
idpId: user.idpId,
|
||||
idpName: user.idpName || "Internal",
|
||||
status: "Confirmed",
|
||||
role: user.isOwner ? "Owner" : user.roleName || "Member",
|
||||
idpName: user.idpName || t('idpNameInternal'),
|
||||
status: t('userConfirmed'),
|
||||
role: user.isOwner ? t('accessRoleOwner') : user.roleName || t('accessRoleMember'),
|
||||
isOwner: user.isOwner || false
|
||||
};
|
||||
});
|
||||
@@ -86,8 +88,8 @@ export default async function UsersPage(props: UsersPageProps) {
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage Users"
|
||||
description="Invite users and add them to roles to manage access to your organization"
|
||||
title={t('accessUsersManage')}
|
||||
description={t('accessUsersDescription')}
|
||||
/>
|
||||
<UserProvider user={user!}>
|
||||
<OrgProvider org={org}>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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>[];
|
||||
@@ -14,15 +15,18 @@ export function OrgApiKeysDataTable<TData, TValue>({
|
||||
columns,
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="API Keys"
|
||||
searchPlaceholder="Search API keys..."
|
||||
title={t('apiKeys')}
|
||||
searchPlaceholder={t('searchApiKeys')}
|
||||
searchColumn="name"
|
||||
onAdd={addApiKey}
|
||||
addButtonText="Generate API Key"
|
||||
addButtonText={t('apiKeysAdd')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ 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;
|
||||
@@ -44,14 +45,16 @@ export default function OrgApiKeysTable({
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const deleteSite = (apiKeyId: string) => {
|
||||
api.delete(`/org/${orgId}/api-key/${apiKeyId}`)
|
||||
.catch((e) => {
|
||||
console.error("Error deleting API key", e);
|
||||
console.error(t('apiKeysErrorDelete'), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error deleting API key",
|
||||
description: formatAxiosError(e, "Error deleting API key")
|
||||
title: t('apiKeysErrorDelete'),
|
||||
description: formatAxiosError(e, t('apiKeysErrorDeleteMessage'))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
@@ -75,7 +78,7 @@ export default function OrgApiKeysTable({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<span className="sr-only">{t('openMenu')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -85,7 +88,7 @@ export default function OrgApiKeysTable({
|
||||
setSelected(apiKeyROw);
|
||||
}}
|
||||
>
|
||||
<span>View settings</span>
|
||||
<span>{t('viewSettings')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -93,7 +96,7 @@ export default function OrgApiKeysTable({
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
<span className="text-red-500">{t('delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -110,7 +113,7 @@ export default function OrgApiKeysTable({
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
{t('name')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -118,7 +121,7 @@ export default function OrgApiKeysTable({
|
||||
},
|
||||
{
|
||||
accessorKey: "key",
|
||||
header: "Key",
|
||||
header: t('key'),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return <span className="font-mono">{r.key}</span>;
|
||||
@@ -126,7 +129,7 @@ export default function OrgApiKeysTable({
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created At",
|
||||
header: t('createdAt'),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return <span>{moment(r.createdAt).format("lll")} </span>;
|
||||
@@ -140,7 +143,7 @@ export default function OrgApiKeysTable({
|
||||
<div className="flex items-center justify-end">
|
||||
<Link href={`/${orgId}/settings/api-keys/${r.id}`}>
|
||||
<Button variant={"outlinePrimary"} className="ml-2">
|
||||
Edit
|
||||
{t('edit')}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -162,28 +165,24 @@ export default function OrgApiKeysTable({
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to remove the API key{" "}
|
||||
<b>{selected?.name || selected?.id}</b> from the
|
||||
organization?
|
||||
{t('apiKeysQuestionRemove', {selectedApiKey: selected?.name || selected?.id})}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>
|
||||
Once removed, the API key will no longer be
|
||||
able to be used.
|
||||
{t('apiKeysMessageRemove')}
|
||||
</b>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the name of the API key
|
||||
below.
|
||||
{t('apiKeysMessageConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete API Key"
|
||||
buttonText={t('apiKeysDeleteConfirm')}
|
||||
onConfirm={async () => deleteSite(selected!.id)}
|
||||
string={selected.name}
|
||||
title="Delete API Key"
|
||||
title={t('apiKeysDelete')}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { GetApiKeyResponse } from "@server/routers/apiKeys";
|
||||
import ApiKeyProvider from "@app/providers/ApiKeyProvider";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
interface SettingsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -23,6 +24,7 @@ interface SettingsLayoutProps {
|
||||
|
||||
export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
const { children } = props;
|
||||
|
||||
@@ -40,14 +42,14 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: "Permissions",
|
||||
title: t('apiKeysPermissionsTitle'),
|
||||
href: "/{orgId}/settings/api-keys/{apiKeyId}/permissions"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle title={`${apiKey?.name} Settings`} />
|
||||
<SettingsSectionTitle title={t('apiKeysSettings', {apiKeyName: apiKey?.name})} />
|
||||
|
||||
<ApiKeyProvider apiKey={apiKey}>
|
||||
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
||||
|
||||
@@ -18,12 +18,15 @@ import { ListApiKeyActionsResponse } from "@server/routers/apiKeys";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Page() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const { orgId, apiKeyId } = useParams();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const [loadingPage, setLoadingPage] = useState<boolean>(true);
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<
|
||||
Record<string, boolean>
|
||||
@@ -42,10 +45,10 @@ export default function Page() {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error loading API key actions",
|
||||
title: t('apiKeysPermissionsErrorLoadingActions'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"Error loading API key actions"
|
||||
t('apiKeysPermissionsErrorLoadingActions')
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -76,18 +79,18 @@ export default function Page() {
|
||||
)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Error setting permissions", e);
|
||||
console.error(t('apiKeysErrorSetPermission'), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error setting permissions",
|
||||
title: t('apiKeysErrorSetPermission'),
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
});
|
||||
|
||||
if (actionsRes && actionsRes.status === 200) {
|
||||
toast({
|
||||
title: "Permissions updated",
|
||||
description: "The permissions have been updated."
|
||||
title: t('apiKeysPermissionsUpdated'),
|
||||
description: t('apiKeysPermissionsUpdatedDescription')
|
||||
});
|
||||
}
|
||||
|
||||
@@ -101,10 +104,10 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Permissions
|
||||
{t('apiKeysPermissionsGeneralSettings')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Determine what this API key can do
|
||||
{t('apiKeysPermissionsGeneralSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -121,7 +124,7 @@ export default function Page() {
|
||||
loading={loadingSavePermissions}
|
||||
disabled={loadingSavePermissions}
|
||||
>
|
||||
Save Permissions
|
||||
{t('apiKeysPermissionsSave')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSectionBody>
|
||||
|
||||
@@ -55,41 +55,14 @@ import moment from "moment";
|
||||
import CopyCodeBox from "@server/emails/templates/components/CopyCodeBox";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
|
||||
|
||||
const createFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: "Name must be at least 2 characters."
|
||||
})
|
||||
.max(255, {
|
||||
message: "Name must not be longer than 255 characters."
|
||||
})
|
||||
});
|
||||
|
||||
type CreateFormValues = z.infer<typeof createFormSchema>;
|
||||
|
||||
const copiedFormSchema = z
|
||||
.object({
|
||||
copied: z.boolean()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
return data.copied;
|
||||
},
|
||||
{
|
||||
message: "You must confirm that you have copied the API key.",
|
||||
path: ["copied"]
|
||||
}
|
||||
);
|
||||
|
||||
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Page() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const { orgId } = useParams();
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [loadingPage, setLoadingPage] = useState(true);
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
@@ -98,6 +71,35 @@ export default function Page() {
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const createFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: t('nameMin', {len: 2})
|
||||
})
|
||||
.max(255, {
|
||||
message: t('nameMax', {len: 255})
|
||||
})
|
||||
});
|
||||
|
||||
type CreateFormValues = z.infer<typeof createFormSchema>;
|
||||
|
||||
const copiedFormSchema = z
|
||||
.object({
|
||||
copied: z.boolean()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
return data.copied;
|
||||
},
|
||||
{
|
||||
message: t('apiKeysConfirmCopy2'),
|
||||
path: ["copied"]
|
||||
}
|
||||
);
|
||||
|
||||
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
|
||||
|
||||
const form = useForm<CreateFormValues>({
|
||||
resolver: zodResolver(createFormSchema),
|
||||
defaultValues: {
|
||||
@@ -126,7 +128,7 @@ export default function Page() {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating API key",
|
||||
title: t('apiKeysErrorCreate'),
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
});
|
||||
@@ -147,10 +149,10 @@ export default function Page() {
|
||||
)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Error setting permissions", e);
|
||||
console.error(t('apiKeysErrorSetPermission'), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error setting permissions",
|
||||
title: t('apiKeysErrorSetPermission'),
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
});
|
||||
@@ -189,8 +191,8 @@ export default function Page() {
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title="Generate API Key"
|
||||
description="Generate a new API key for your organization"
|
||||
title={t('apiKeysCreate')}
|
||||
description={t('apiKeysCreateDescription')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -198,7 +200,7 @@ export default function Page() {
|
||||
router.push(`/${orgId}/settings/api-keys`);
|
||||
}}
|
||||
>
|
||||
See All API Keys
|
||||
{t('apiKeysSeeAll')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -210,7 +212,7 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
API Key Information
|
||||
{t('apiKeysTitle')}
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -226,7 +228,7 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name
|
||||
{t('name')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -247,10 +249,10 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Permissions
|
||||
{t('apiKeysGeneralSettings')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Determine what this API key can do
|
||||
{t('apiKeysGeneralSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -269,14 +271,14 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Your API Key
|
||||
{t('apiKeysList')}
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<InfoSections cols={2}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Name
|
||||
{t('name')}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
@@ -286,7 +288,7 @@ export default function Page() {
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Created
|
||||
{t('created')}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{moment(
|
||||
@@ -299,17 +301,15 @@ export default function Page() {
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Save Your API Key
|
||||
{t('apiKeysSave')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
You will only be able to see this
|
||||
once. Make sure to copy it to a
|
||||
secure place.
|
||||
{t('apiKeysSaveDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<h4 className="font-semibold">
|
||||
Your API key is:
|
||||
{t('apiKeysInfo')}
|
||||
</h4>
|
||||
|
||||
<CopyTextBox
|
||||
@@ -347,8 +347,7 @@ export default function Page() {
|
||||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
I have copied
|
||||
the API key
|
||||
{t('apiKeysConfirmCopy')}
|
||||
</label>
|
||||
</div>
|
||||
<FormMessage />
|
||||
@@ -372,7 +371,7 @@ export default function Page() {
|
||||
router.push(`/${orgId}/settings/api-keys`);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{!apiKey && (
|
||||
@@ -384,7 +383,7 @@ export default function Page() {
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
>
|
||||
Generate
|
||||
{t('generate')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -395,7 +394,7 @@ export default function Page() {
|
||||
copiedForm.handleSubmit(onCopiedSubmit)();
|
||||
}}
|
||||
>
|
||||
Done
|
||||
{t('done')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AxiosResponse } from "axios";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import OrgApiKeysTable, { OrgApiKeyRow } from "./OrgApiKeysTable";
|
||||
import { ListOrgApiKeysResponse } from "@server/routers/apiKeys";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
type ApiKeyPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
@@ -13,6 +14,8 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ApiKeysPage(props: ApiKeyPageProps) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
let apiKeys: ListOrgApiKeysResponse["apiKeys"] = [];
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListOrgApiKeysResponse>>(
|
||||
@@ -34,8 +37,8 @@ export default async function ApiKeysPage(props: ApiKeyPageProps) {
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage API Keys"
|
||||
description="API keys are used to authenticate with the integration API"
|
||||
title={t('apiKeysManage')}
|
||||
description={t('apiKeysDescription')}
|
||||
/>
|
||||
|
||||
<OrgApiKeysTable apiKeys={rows} orgId={params.orgId} />
|
||||
|
||||
@@ -10,6 +10,7 @@ import { GetOrgUserResponse } from "@server/routers/user";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
type GeneralSettingsProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -57,9 +58,11 @@ export default async function GeneralSettingsPage({
|
||||
redirect(`/${orgId}`);
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: "General",
|
||||
title: t('general'),
|
||||
href: `/{orgId}/settings/general`,
|
||||
},
|
||||
];
|
||||
@@ -69,8 +72,8 @@ export default async function GeneralSettingsPage({
|
||||
<OrgProvider org={org}>
|
||||
<OrgUserProvider orgUser={orgUser}>
|
||||
<SettingsSectionTitle
|
||||
title="General"
|
||||
description="Configure your organization's general settings"
|
||||
title={t('general')}
|
||||
description={t('orgSettingsDescription')}
|
||||
/>
|
||||
|
||||
<HorizontalTabs items={navItems}>
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
// Updated schema to include subnet field
|
||||
const GeneralFormSchema = z.object({
|
||||
@@ -51,6 +52,7 @@ export default function GeneralPage() {
|
||||
const { org } = useOrgContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { user } = useUserContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const [loadingDelete, setLoadingDelete] = useState(false);
|
||||
const [loadingSave, setLoadingSave] = useState(false);
|
||||
@@ -71,8 +73,8 @@ export default function GeneralPage() {
|
||||
`/org/${org?.org.orgId}`
|
||||
);
|
||||
toast({
|
||||
title: "Organization deleted",
|
||||
description: "The organization and its data has been deleted."
|
||||
title: t('orgDeleted'),
|
||||
description: t('orgDeletedMessage')
|
||||
});
|
||||
if (res.status === 200) {
|
||||
pickNewOrgAndNavigate();
|
||||
@@ -81,11 +83,8 @@ export default function GeneralPage() {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to delete org",
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while deleting the org."
|
||||
)
|
||||
title: t('orgErrorDelete'),
|
||||
description: formatAxiosError(err, t('orgErrorDeleteMessage'))
|
||||
});
|
||||
} finally {
|
||||
setLoadingDelete(false);
|
||||
@@ -112,11 +111,8 @@ export default function GeneralPage() {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch orgs",
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while listing your orgs"
|
||||
)
|
||||
title: t('orgErrorFetch'),
|
||||
description: formatAxiosError(err, t('orgErrorFetchMessage'))
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -130,19 +126,16 @@ export default function GeneralPage() {
|
||||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: "Organization updated",
|
||||
description: "The organization has been updated."
|
||||
title: t('orgUpdated'),
|
||||
description: t('orgUpdatedDescription')
|
||||
});
|
||||
router.refresh();
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update org",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while updating the org."
|
||||
)
|
||||
title: t('orgErrorUpdate'),
|
||||
description: formatAxiosError(e, t('orgErrorUpdateMessage'))
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -160,30 +153,28 @@ export default function GeneralPage() {
|
||||
dialog={
|
||||
<div>
|
||||
<p className="mb-2">
|
||||
Are you sure you want to delete the organization{" "}
|
||||
<b>{org?.org.name}?</b>
|
||||
{t('orgQuestionRemove', {selectedOrg: org?.org.name})}
|
||||
</p>
|
||||
<p className="mb-2">
|
||||
This action is irreversible and will delete all
|
||||
associated data.
|
||||
{t('orgMessageRemove')}
|
||||
</p>
|
||||
<p>
|
||||
To confirm, type the name of the organization below.
|
||||
{t('orgMessageConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete Organization"
|
||||
buttonText={t('orgDeleteConfirm')}
|
||||
onConfirm={deleteOrg}
|
||||
string={org?.org.name || ""}
|
||||
title="Delete Organization"
|
||||
title={t('orgDelete')}
|
||||
/>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Organization Settings
|
||||
{t('orgGeneralSettings')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Manage your organization details and configuration
|
||||
{t('orgGeneralSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -199,14 +190,13 @@ export default function GeneralPage() {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>{t('name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the display name of the
|
||||
organization.
|
||||
{t('orgDisplayName')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -242,16 +232,15 @@ export default function GeneralPage() {
|
||||
loading={loadingSave}
|
||||
disabled={loadingSave}
|
||||
>
|
||||
Save General Settings
|
||||
{t('saveGeneralSettings')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>Danger Zone</SettingsSectionTitle>
|
||||
<SettingsSectionTitle>{t('orgDangerZone')}</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Once you delete this org, there is no going back. Please
|
||||
be certain.
|
||||
{t('orgDangerZoneDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionFooter>
|
||||
@@ -262,7 +251,7 @@ export default function GeneralPage() {
|
||||
loading={loadingDelete}
|
||||
disabled={loadingDelete}
|
||||
>
|
||||
Delete Organization Data
|
||||
{t('orgDelete')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -19,6 +19,7 @@ import UserProvider from "@app/providers/UserProvider";
|
||||
import { Layout } from "@app/components/Layout";
|
||||
import { SidebarNavItem, SidebarNavProps } from "@app/components/SidebarNav";
|
||||
import { orgNavItems } from "@app/app/navigation";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -79,6 +80,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
|
||||
const cookie = await authCookieHeader();
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
try {
|
||||
const getOrgUser = cache(() =>
|
||||
internal.get<AxiosResponse<GetOrgUserResponse>>(
|
||||
@@ -89,7 +92,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
const orgUser = await getOrgUser();
|
||||
|
||||
if (!orgUser.data.data.isAdmin && !orgUser.data.data.isOwner) {
|
||||
throw new Error("User is not an admin or owner");
|
||||
throw new Error(t('userErrorNotAdminOrOwner'));
|
||||
}
|
||||
} catch {
|
||||
redirect(`/${params.orgId}`);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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>[];
|
||||
@@ -14,15 +15,18 @@ export function ResourcesDataTable<TData, TValue>({
|
||||
data,
|
||||
createResource
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="Resources"
|
||||
searchPlaceholder="Search resources..."
|
||||
title={t('resources')}
|
||||
searchPlaceholder={t('resourcesSearch')}
|
||||
searchColumn="name"
|
||||
onAdd={createResource}
|
||||
addButtonText="Add Resource"
|
||||
addButtonText={t('resourceAdd')}
|
||||
defaultSort={{
|
||||
id: "name",
|
||||
desc: false
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useState, useEffect } from "react";
|
||||
import { Server, Lock, Key, Users, X, ArrowRight } 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 ResourcesSplashCard = () => {
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
@@ -22,6 +23,8 @@ export const ResourcesSplashCard = () => {
|
||||
localStorage.setItem(key, "true");
|
||||
};
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
if (isDismissed) {
|
||||
return null;
|
||||
}
|
||||
@@ -31,7 +34,7 @@ export const ResourcesSplashCard = () => {
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-2 right-2 p-2"
|
||||
aria-label="Dismiss"
|
||||
aria-label={t('dismiss')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -39,24 +42,23 @@ export const ResourcesSplashCard = () => {
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Server className="text-blue-500" />
|
||||
Resources
|
||||
{t('resources')}
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
Resources are proxies to applications running on your private network. Create a resource for any HTTP/HTTPS or raw TCP/UDP service on your private network.
|
||||
Each resource must be connected to a site to enable private, secure connectivity through an encrypted WireGuard tunnel.
|
||||
{t('resourcesDescription')}
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li className="flex items-center gap-2">
|
||||
<Lock className="text-green-500 w-4 h-4" />
|
||||
Secure connectivity with WireGuard encryption
|
||||
{t('resourcesWireGuardConnect')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Key className="text-yellow-500 w-4 h-4" />
|
||||
Configure multiple authentication methods
|
||||
{t('resourcesMultipleAuthenticationMethods')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Users className="text-purple-500 w-4 h-4" />
|
||||
User and role-based access control
|
||||
{t('resourcesUsersRolesAccess')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,7 @@ import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export type ResourceRow = {
|
||||
id: number;
|
||||
@@ -53,6 +54,7 @@ type ResourcesTableProps = {
|
||||
|
||||
export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
@@ -63,11 +65,11 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
const deleteResource = (resourceId: number) => {
|
||||
api.delete(`/resource/${resourceId}`)
|
||||
.catch((e) => {
|
||||
console.error("Error deleting resource", e);
|
||||
console.error(t('resourceErrorDelte'), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error deleting resource",
|
||||
description: formatAxiosError(e, "Error deleting resource")
|
||||
title: t('resourceErrorDelte'),
|
||||
description: formatAxiosError(e, t('resourceErrorDelte'))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
@@ -87,11 +89,8 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to toggle resource",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while updating the resource"
|
||||
)
|
||||
title: t('resourcesErrorUpdate'),
|
||||
description: formatAxiosError(e, t('resourcesErrorUpdateDescription'))
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -108,7 +107,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<span className="sr-only">{t('openMenu')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -118,7 +117,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
View settings
|
||||
{t('viewSettings')}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
@@ -127,7 +126,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
<span className="text-red-500">{t('delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -144,7 +143,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
{t('name')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -160,7 +159,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Site
|
||||
{t('site')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -181,7 +180,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
},
|
||||
{
|
||||
accessorKey: "protocol",
|
||||
header: "Protocol",
|
||||
header: t('protocol'),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return <span>{resourceRow.protocol.toUpperCase()}</span>;
|
||||
@@ -189,7 +188,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
},
|
||||
{
|
||||
accessorKey: "domain",
|
||||
header: "Access",
|
||||
header: t('access'),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
@@ -219,7 +218,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Authentication
|
||||
{t('authentication')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -231,12 +230,12 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
{resourceRow.authState === "protected" ? (
|
||||
<span className="text-green-500 flex items-center space-x-2">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<span>Protected</span>
|
||||
<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>Not Protected</span>
|
||||
<span>{t('notProtected')}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>-</span>
|
||||
@@ -247,7 +246,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
header: "Enabled",
|
||||
header: t('enabled'),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.original.enabled}
|
||||
@@ -267,7 +266,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
|
||||
>
|
||||
<Button variant={"outlinePrimary"} className="ml-2">
|
||||
Edit
|
||||
{t('edit')}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -289,30 +288,22 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
dialog={
|
||||
<div>
|
||||
<p className="mb-2">
|
||||
Are you sure you want to remove the resource{" "}
|
||||
<b>
|
||||
{selectedResource?.name ||
|
||||
selectedResource?.id}
|
||||
</b>{" "}
|
||||
from the organization?
|
||||
{t('resourceQuestionRemove', {selectedResource: selectedResource?.name || selectedResource?.id})}
|
||||
</p>
|
||||
|
||||
<p className="mb-2">
|
||||
Once removed, the resource will no longer be
|
||||
accessible. All targets attached to the resource
|
||||
will be removed.
|
||||
{t('resourceMessageRemove')}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the name of the resource
|
||||
below.
|
||||
{t('resourceMessageConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete Resource"
|
||||
buttonText={t('resourceDeleteConfirm')}
|
||||
onConfirm={async () => deleteResource(selectedResource!.id)}
|
||||
string={selectedResource.name}
|
||||
title="Delete Resource"
|
||||
title={t('resourceDelete')}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useDockerSocket } from "@app/hooks/useDockerSocket";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type ResourceInfoBoxType = {};
|
||||
|
||||
@@ -21,6 +22,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const { isEnabled, isAvailable } = useDockerSocket(site!);
|
||||
const t = useTranslations();
|
||||
|
||||
let fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||
|
||||
@@ -28,7 +30,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Resource Information
|
||||
{t('resourceInfo')}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<InfoSections cols={4}>
|
||||
@@ -36,7 +38,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Authentication
|
||||
{t('authentication')}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{authInfo.password ||
|
||||
@@ -45,12 +47,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
authInfo.whitelist ? (
|
||||
<div className="flex items-start space-x-2 text-green-500">
|
||||
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
||||
<span>Protected</span>
|
||||
<span>{t('protected')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2 text-yellow-500">
|
||||
<ShieldOff className="w-4 h-4" />
|
||||
<span>Not Protected</span>
|
||||
<span>{t('notProtected')}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
@@ -65,7 +67,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Site</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t('site')}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{resource.siteName}
|
||||
</InfoSectionContent>
|
||||
@@ -92,7 +94,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
) : (
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Protocol</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t('protocol')}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span>
|
||||
{resource.protocol.toUpperCase()}
|
||||
@@ -100,7 +102,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Port</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t('port')}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={resource.proxyPort!.toString()}
|
||||
@@ -111,10 +113,10 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
</>
|
||||
)}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Visibility</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t('visibility')}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span>
|
||||
{resource.enabled ? "Enabled" : "Disabled"}
|
||||
{resource.enabled ? t('enabled') : t('disabled')}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
|
||||
@@ -31,6 +31,7 @@ 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)
|
||||
@@ -56,6 +57,7 @@ export default function SetResourcePasswordForm({
|
||||
onSetPassword
|
||||
}: SetPasswordFormProps) {
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -81,18 +83,17 @@ export default function SetResourcePasswordForm({
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error setting resource password",
|
||||
title: t('resourceErrorPasswordSetup'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while setting the resource password"
|
||||
t('resourceErrorPasswordSetupDescription')
|
||||
)
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: "Resource password set",
|
||||
description:
|
||||
"The resource password has been set successfully"
|
||||
title: t('resourcePasswordSetup'),
|
||||
description: t('resourcePasswordSetupDescription')
|
||||
});
|
||||
|
||||
if (onSetPassword) {
|
||||
@@ -114,9 +115,9 @@ export default function SetResourcePasswordForm({
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Set Password</CredenzaTitle>
|
||||
<CredenzaTitle>{t('resourcePasswordSetupTitle')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Set a password to protect this resource
|
||||
{t('resourcePasswordSetupTitleDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
@@ -131,7 +132,7 @@ export default function SetResourcePasswordForm({
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormLabel>{t('password')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
@@ -148,7 +149,7 @@ export default function SetResourcePasswordForm({
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -156,7 +157,7 @@ export default function SetResourcePasswordForm({
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Enable Password Protection
|
||||
{t('resourcePasswordSubmit')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} 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)
|
||||
@@ -69,6 +70,8 @@ export default function SetResourcePincodeForm({
|
||||
defaultValues
|
||||
});
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
@@ -86,18 +89,17 @@ export default function SetResourcePincodeForm({
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error setting resource PIN code",
|
||||
title: t('resourceErrorPincodeSetup'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while setting the resource PIN code"
|
||||
t('resourceErrorPincodeSetupDescription')
|
||||
)
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: "Resource PIN code set",
|
||||
description:
|
||||
"The resource pincode has been set successfully"
|
||||
title: t('resourcePincodeSetup'),
|
||||
description: t('resourcePincodeSetupDescription')
|
||||
});
|
||||
|
||||
if (onSetPincode) {
|
||||
@@ -119,9 +121,9 @@ export default function SetResourcePincodeForm({
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Set Pincode</CredenzaTitle>
|
||||
<CredenzaTitle>{t('resourcePincodeSetupTitle')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Set a pincode to protect this resource
|
||||
{t('resourcePincodeSetupTitleDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
@@ -136,7 +138,7 @@ export default function SetResourcePincodeForm({
|
||||
name="pincode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>PIN Code</FormLabel>
|
||||
<FormLabel>{t('resourcePincode')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
@@ -182,7 +184,7 @@ export default function SetResourcePincodeForm({
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -190,7 +192,7 @@ export default function SetResourcePincodeForm({
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Enable PIN Code Protection
|
||||
{t('resourcePincodeSubmit')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
||||
@@ -48,6 +48,7 @@ import { useRouter } from "next/navigation";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const UsersRolesFormSchema = z.object({
|
||||
roles: z.array(
|
||||
@@ -82,6 +83,7 @@ export default function ResourceAuthenticationPage() {
|
||||
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
|
||||
@@ -203,10 +205,10 @@ export default function ResourceAuthenticationPage() {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch data",
|
||||
title: t('resourceErrorAuthFetch'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while fetching the data"
|
||||
t('resourceErrorAuthFetchDescription')
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -233,18 +235,18 @@ export default function ResourceAuthenticationPage() {
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Saved successfully",
|
||||
description: "Whitelist settings have been saved"
|
||||
title: t('resourceWhitelistSave'),
|
||||
description: t('resourceWhitelistSaveDescription')
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to save whitelist",
|
||||
title: t('resourceErrorWhitelistSave'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while saving the whitelist"
|
||||
t('resourceErrorWhitelistSaveDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
@@ -281,18 +283,18 @@ export default function ResourceAuthenticationPage() {
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Saved successfully",
|
||||
description: "Authentication settings have been saved"
|
||||
title: t('resourceAuthSettingsSave'),
|
||||
description: t('resourceAuthSettingsSaveDescription')
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to set roles",
|
||||
title: t('resourceErrorUsersRolesSave'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while setting the roles"
|
||||
t('resourceErrorUsersRolesSaveDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
@@ -308,9 +310,8 @@ export default function ResourceAuthenticationPage() {
|
||||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: "Resource password removed",
|
||||
description:
|
||||
"The resource password has been removed successfully"
|
||||
title: t('resourcePasswordRemove'),
|
||||
description: t('resourcePasswordRemoveDescription')
|
||||
});
|
||||
|
||||
updateAuthInfo({
|
||||
@@ -321,10 +322,10 @@ export default function ResourceAuthenticationPage() {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error removing resource password",
|
||||
title: t('resourceErrorPasswordRemove'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while removing the resource password"
|
||||
t('resourceErrorPasswordRemoveDescription')
|
||||
)
|
||||
});
|
||||
})
|
||||
@@ -339,9 +340,8 @@ export default function ResourceAuthenticationPage() {
|
||||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: "Resource pincode removed",
|
||||
description:
|
||||
"The resource password has been removed successfully"
|
||||
title: t('resourcePincodeRemove'),
|
||||
description: t('resourcePincodeRemoveDescription')
|
||||
});
|
||||
|
||||
updateAuthInfo({
|
||||
@@ -352,10 +352,10 @@ export default function ResourceAuthenticationPage() {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error removing resource pincode",
|
||||
title: t('resourceErrorPincodeRemove'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while removing the resource pincode"
|
||||
t('resourceErrorPincodeRemoveDescription')
|
||||
)
|
||||
});
|
||||
})
|
||||
@@ -400,18 +400,17 @@ export default function ResourceAuthenticationPage() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Users & Roles
|
||||
{t('resourceUsersRoles')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure which users and roles can visit this
|
||||
resource
|
||||
{t('resourceUsersRolesDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SwitchInput
|
||||
id="sso-toggle"
|
||||
label="Use Platform SSO"
|
||||
description="Existing users will only have to log in once for all resources that have this enabled."
|
||||
label={t('ssoUse')}
|
||||
description={t('ssoUseDescription')}
|
||||
defaultChecked={resource.sso}
|
||||
onCheckedChange={(val) => setSsoEnabled(val)}
|
||||
/>
|
||||
@@ -431,7 +430,7 @@ export default function ResourceAuthenticationPage() {
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>Roles</FormLabel>
|
||||
<FormLabel>{t('roles')}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
@@ -441,7 +440,7 @@ export default function ResourceAuthenticationPage() {
|
||||
setActiveTagIndex={
|
||||
setActiveRolesTagIndex
|
||||
}
|
||||
placeholder="Select a role"
|
||||
placeholder={t('accessRoleSelect2')}
|
||||
size="sm"
|
||||
tags={
|
||||
usersRolesForm.getValues()
|
||||
@@ -475,8 +474,7 @@ export default function ResourceAuthenticationPage() {
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
Admins can always access
|
||||
this resource.
|
||||
{t('resourceRoleDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -486,7 +484,7 @@ export default function ResourceAuthenticationPage() {
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>Users</FormLabel>
|
||||
<FormLabel>{t('users')}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
@@ -496,7 +494,7 @@ export default function ResourceAuthenticationPage() {
|
||||
setActiveTagIndex={
|
||||
setActiveUsersTagIndex
|
||||
}
|
||||
placeholder="Select a user"
|
||||
placeholder={t('accessUserSelect')}
|
||||
tags={
|
||||
usersRolesForm.getValues()
|
||||
.users
|
||||
@@ -544,7 +542,7 @@ export default function ResourceAuthenticationPage() {
|
||||
disabled={loadingSaveUsersRoles}
|
||||
form="users-roles-form"
|
||||
>
|
||||
Save Users & Roles
|
||||
{t('resourceUsersRolesSubmit')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
@@ -552,11 +550,10 @@ export default function ResourceAuthenticationPage() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Authentication Methods
|
||||
{t('resourceAuthMethods')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Allow access to the resource via additional auth
|
||||
methods
|
||||
{t('resourceAuthMethodsDescriptions')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -567,8 +564,7 @@ export default function ResourceAuthenticationPage() {
|
||||
>
|
||||
<Key />
|
||||
<span>
|
||||
Password Protection{" "}
|
||||
{authInfo.password ? "Enabled" : "Disabled"}
|
||||
{t('resourcePasswordProtection', {status: authInfo.password? t('enabled') : t('disabled')})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
@@ -581,8 +577,8 @@ export default function ResourceAuthenticationPage() {
|
||||
loading={loadingRemoveResourcePassword}
|
||||
>
|
||||
{authInfo.password
|
||||
? "Remove Password"
|
||||
: "Add Password"}
|
||||
? t('passwordRemove')
|
||||
: t('passwordAdd')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -593,8 +589,7 @@ export default function ResourceAuthenticationPage() {
|
||||
>
|
||||
<Binary />
|
||||
<span>
|
||||
PIN Code Protection{" "}
|
||||
{authInfo.pincode ? "Enabled" : "Disabled"}
|
||||
{t('resourcePincodeProtection', {status: authInfo.pincode ? t('enabled') : t('disabled')})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
@@ -607,8 +602,8 @@ export default function ResourceAuthenticationPage() {
|
||||
loading={loadingRemoveResourcePincode}
|
||||
>
|
||||
{authInfo.pincode
|
||||
? "Remove PIN Code"
|
||||
: "Add PIN Code"}
|
||||
? t('pincodeRemove')
|
||||
: t('pincodeAdd')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
@@ -617,11 +612,10 @@ export default function ResourceAuthenticationPage() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
One-time Passwords
|
||||
{t('otpEmailTitle')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Require email-based authentication for resource
|
||||
access
|
||||
{t('otpEmailTitleDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -629,16 +623,16 @@ export default function ResourceAuthenticationPage() {
|
||||
<Alert variant="neutral" className="mb-4">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
SMTP Required
|
||||
{t('otpEmailSmtpRequired')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
SMTP must be enabled on the server to use one-time password authentication.
|
||||
{t('otpEmailSmtpRequiredDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<SwitchInput
|
||||
id="whitelist-toggle"
|
||||
label="Email Whitelist"
|
||||
label={t('otpEmailWhitelist')}
|
||||
defaultChecked={resource.emailWhitelistEnabled}
|
||||
onCheckedChange={setWhitelistEnabled}
|
||||
disabled={!env.email.emailEnabled}
|
||||
@@ -654,8 +648,8 @@ export default function ResourceAuthenticationPage() {
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<InfoPopup
|
||||
text="Whitelisted Emails"
|
||||
info="Only users with these email addresses will be able to access this resource. They will be prompted to enter a one-time password sent to their email. Wildcards (*@example.com) can be used to allow any email address from a domain."
|
||||
text={t('otpEmailWhitelistList')}
|
||||
info={t('otpEmailWhitelistListDescription')}
|
||||
/>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
@@ -678,8 +672,7 @@ export default function ResourceAuthenticationPage() {
|
||||
.regex(
|
||||
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
|
||||
{
|
||||
message:
|
||||
"Invalid email address. Wildcard (*) must be the entire local part."
|
||||
message: t('otpEmailErrorInvalid')
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -690,7 +683,7 @@ export default function ResourceAuthenticationPage() {
|
||||
setActiveTagIndex={
|
||||
setActiveEmailTagIndex
|
||||
}
|
||||
placeholder="Enter an email"
|
||||
placeholder={t('otpEmailEnter')}
|
||||
tags={
|
||||
whitelistForm.getValues()
|
||||
.emails
|
||||
@@ -713,9 +706,7 @@ export default function ResourceAuthenticationPage() {
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Press enter to add an
|
||||
email after typing it in
|
||||
the input field.
|
||||
{t('otpEmailEnterDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -731,7 +722,7 @@ export default function ResourceAuthenticationPage() {
|
||||
loading={loadingSaveWhitelist}
|
||||
disabled={loadingSaveWhitelist}
|
||||
>
|
||||
Save Whitelist
|
||||
{t('otpEmailWhitelistSave')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -65,51 +65,12 @@ import {
|
||||
updateResourceRule
|
||||
} from "@server/routers/resource";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
|
||||
const GeneralFormSchema = z
|
||||
.object({
|
||||
subdomain: z.string().optional(),
|
||||
name: z.string().min(1).max(255),
|
||||
proxyPort: z.number().optional(),
|
||||
http: z.boolean(),
|
||||
isBaseDomain: z.boolean().optional(),
|
||||
domainId: z.string().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.http) {
|
||||
return z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(65535)
|
||||
.safeParse(data.proxyPort).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Invalid port number",
|
||||
path: ["proxyPort"]
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.http && !data.isBaseDomain) {
|
||||
return subdomainSchema.safeParse(data.subdomain).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Invalid subdomain",
|
||||
path: ["subdomain"]
|
||||
}
|
||||
);
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const TransferFormSchema = z.object({
|
||||
siteId: z.number()
|
||||
});
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
type TransferFormValues = z.infer<typeof TransferFormSchema>;
|
||||
|
||||
export default function GeneralForm() {
|
||||
@@ -118,6 +79,7 @@ export default function GeneralForm() {
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
const { org } = useOrgContext();
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
||||
@@ -138,6 +100,47 @@ export default function GeneralForm() {
|
||||
resource.isBaseDomain ? "basedomain" : "subdomain"
|
||||
);
|
||||
|
||||
const GeneralFormSchema = z
|
||||
.object({
|
||||
subdomain: z.string().optional(),
|
||||
name: z.string().min(1).max(255),
|
||||
proxyPort: z.number().optional(),
|
||||
http: z.boolean(),
|
||||
isBaseDomain: z.boolean().optional(),
|
||||
domainId: z.string().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.http) {
|
||||
return z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(65535)
|
||||
.safeParse(data.proxyPort).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t("proxyErrorInvalidPort"),
|
||||
path: ["proxyPort"]
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.http && !data.isBaseDomain) {
|
||||
return subdomainSchema.safeParse(data.subdomain).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t("subdomainErrorInvalid"),
|
||||
path: ["subdomain"]
|
||||
}
|
||||
);
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
|
||||
const form = useForm<GeneralFormValues>({
|
||||
resolver: zodResolver(GeneralFormSchema),
|
||||
defaultValues: {
|
||||
@@ -174,10 +177,10 @@ export default function GeneralForm() {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error fetching domains",
|
||||
title: t("domainErrorFetch"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred when fetching the domains"
|
||||
t("domainErrorFetchDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -216,18 +219,18 @@ export default function GeneralForm() {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update resource",
|
||||
title: t("resourceErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while updating the resource"
|
||||
t("resourceErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
title: "Resource updated",
|
||||
description: "The resource has been updated successfully"
|
||||
title: t("resourceUpdated"),
|
||||
description: t("resourceUpdatedDescription")
|
||||
});
|
||||
|
||||
const resource = res.data.data;
|
||||
@@ -255,18 +258,18 @@ export default function GeneralForm() {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to transfer resource",
|
||||
title: t("resourceErrorTransfer"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while transferring the resource"
|
||||
t("resourceErrorTransferDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
title: "Resource transferred",
|
||||
description: "The resource has been transferred successfully"
|
||||
title: t("resourceTransferred"),
|
||||
description: t("resourceTransferredDescription")
|
||||
});
|
||||
router.refresh();
|
||||
|
||||
@@ -290,10 +293,10 @@ export default function GeneralForm() {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to toggle resource",
|
||||
title: t("resourceErrorToggle"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while updating the resource"
|
||||
t("resourceErrorToggleDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -308,15 +311,17 @@ export default function GeneralForm() {
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>Visibility</SettingsSectionTitle>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceVisibilityTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Completely enable or disable resource visibility
|
||||
{t("resourceVisibilityTitleDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SwitchInput
|
||||
id="enable-resource"
|
||||
label="Enable Resource"
|
||||
label={t("resourceEnable")}
|
||||
defaultChecked={resource.enabled}
|
||||
onCheckedChange={async (val) => {
|
||||
await toggleResourceEnabled(val);
|
||||
@@ -328,10 +333,10 @@ export default function GeneralForm() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
General Settings
|
||||
{t("resourceGeneral")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the general settings for this resource
|
||||
{t("resourceGeneralDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
@@ -348,7 +353,9 @@ export default function GeneralForm() {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
@@ -367,7 +374,9 @@ export default function GeneralForm() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Domain Type
|
||||
{t(
|
||||
"domainType"
|
||||
)}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={
|
||||
@@ -398,11 +407,14 @@ export default function GeneralForm() {
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="subdomain">
|
||||
Subdomain
|
||||
{t(
|
||||
"subdomain"
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value="basedomain">
|
||||
Base
|
||||
Domain
|
||||
{t(
|
||||
"baseDomain"
|
||||
)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -416,7 +428,7 @@ export default function GeneralForm() {
|
||||
{domainType === "subdomain" ? (
|
||||
<div className="w-fill space-y-2">
|
||||
<FormLabel>
|
||||
Subdomain
|
||||
{t("subdomain")}
|
||||
</FormLabel>
|
||||
<div className="flex">
|
||||
<div className="w-1/2">
|
||||
@@ -502,7 +514,9 @@ export default function GeneralForm() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Base Domain
|
||||
{t(
|
||||
"baseDomain"
|
||||
)}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
@@ -556,7 +570,9 @@ export default function GeneralForm() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Port Number
|
||||
{t(
|
||||
"resourcePortNumber"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -596,7 +612,7 @@ export default function GeneralForm() {
|
||||
disabled={saveLoading}
|
||||
form="general-settings-form"
|
||||
>
|
||||
Save General Settings
|
||||
{t("saveGeneralSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
@@ -604,10 +620,10 @@ export default function GeneralForm() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Transfer Resource
|
||||
{t("resourceTransfer")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Transfer this resource to a different site
|
||||
{t("resourceTransferDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
@@ -627,7 +643,7 @@ export default function GeneralForm() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Destination Site
|
||||
{t("siteDestination")}
|
||||
</FormLabel>
|
||||
<Popover
|
||||
open={open}
|
||||
@@ -652,16 +668,24 @@ export default function GeneralForm() {
|
||||
site.siteId ===
|
||||
field.value
|
||||
)?.name
|
||||
: "Select site"}
|
||||
: t(
|
||||
"siteSelect"
|
||||
)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search sites" />
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"searchSites"
|
||||
)}
|
||||
/>
|
||||
<CommandEmpty>
|
||||
No sites found.
|
||||
{t(
|
||||
"sitesNotFound"
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sites.map(
|
||||
@@ -716,7 +740,7 @@ export default function GeneralForm() {
|
||||
disabled={transferLoading}
|
||||
form="transfer-form"
|
||||
>
|
||||
Transfer Resource
|
||||
{t("resourceTransferSubmit")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -14,6 +14,7 @@ import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { cache } from "react";
|
||||
import ResourceInfoBox from "./ResourceInfoBox";
|
||||
import { GetSiteResponse } from "@server/routers/site";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
interface ResourceLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -22,6 +23,7 @@ interface ResourceLayoutProps {
|
||||
|
||||
export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
const { children } = props;
|
||||
|
||||
@@ -88,22 +90,22 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: "General",
|
||||
title: t('general'),
|
||||
href: `/{orgId}/settings/resources/{resourceId}/general`
|
||||
},
|
||||
{
|
||||
title: "Proxy",
|
||||
title: t('proxy'),
|
||||
href: `/{orgId}/settings/resources/{resourceId}/proxy`
|
||||
}
|
||||
];
|
||||
|
||||
if (resource.http) {
|
||||
navItems.push({
|
||||
title: "Authentication",
|
||||
title: t('authentication'),
|
||||
href: `/{orgId}/settings/resources/{resourceId}/authentication`
|
||||
});
|
||||
navItems.push({
|
||||
title: "Rules",
|
||||
title: t('rules'),
|
||||
href: `/{orgId}/settings/resources/{resourceId}/rules`
|
||||
});
|
||||
}
|
||||
@@ -111,8 +113,8 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={`${resource?.name} Settings`}
|
||||
description="Configure the settings on your resource"
|
||||
title={t('resourceSetting', {resourceName: resource?.name})}
|
||||
description={t('resourceSettingDescription')}
|
||||
/>
|
||||
|
||||
<OrgProvider org={org}>
|
||||
|
||||
@@ -74,7 +74,7 @@ import {
|
||||
CollapsibleTrigger
|
||||
} from "@app/components/ui/collapsible";
|
||||
import { ContainersSelector } from "@app/components/ContainersSelector";
|
||||
import { FaDocker } from "react-icons/fa";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const addTargetSchema = z.object({
|
||||
ip: z.string().refine(isTargetValid),
|
||||
@@ -94,51 +94,11 @@ type LocalTarget = Omit<
|
||||
"protocol"
|
||||
>;
|
||||
|
||||
const proxySettingsSchema = z.object({
|
||||
setHostHeader: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data) {
|
||||
return tlsNameSchema.safeParse(data).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header."
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
const tlsSettingsSchema = z.object({
|
||||
ssl: z.boolean(),
|
||||
tlsServerName: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data) {
|
||||
return tlsNameSchema.safeParse(data).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name."
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
type ProxySettingsValues = z.infer<typeof proxySettingsSchema>;
|
||||
type TlsSettingsValues = z.infer<typeof tlsSettingsSchema>;
|
||||
type TargetsSettingsValues = z.infer<typeof targetsSettingsSchema>;
|
||||
|
||||
export default function ReverseProxyTargets(props: {
|
||||
params: Promise<{ resourceId: number }>;
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
const t = useTranslations();
|
||||
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
|
||||
@@ -156,6 +116,45 @@ export default function ReverseProxyTargets(props: {
|
||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const proxySettingsSchema = z.object({
|
||||
setHostHeader: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data) {
|
||||
return tlsNameSchema.safeParse(data).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t('proxyErrorInvalidHeader')
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
const tlsSettingsSchema = z.object({
|
||||
ssl: z.boolean(),
|
||||
tlsServerName: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data) {
|
||||
return tlsNameSchema.safeParse(data).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t('proxyErrorTls')
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
type ProxySettingsValues = z.infer<typeof proxySettingsSchema>;
|
||||
type TlsSettingsValues = z.infer<typeof tlsSettingsSchema>;
|
||||
type TargetsSettingsValues = z.infer<typeof targetsSettingsSchema>;
|
||||
|
||||
const addTargetForm = useForm({
|
||||
resolver: zodResolver(addTargetSchema),
|
||||
defaultValues: {
|
||||
@@ -204,10 +203,10 @@ export default function ReverseProxyTargets(props: {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch targets",
|
||||
title: t('targetErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while fetching targets"
|
||||
t('targetErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
@@ -229,10 +228,10 @@ export default function ReverseProxyTargets(props: {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch resource",
|
||||
title: t('siteErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while fetching resource"
|
||||
t('siteErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -252,8 +251,8 @@ export default function ReverseProxyTargets(props: {
|
||||
if (isDuplicate) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Duplicate target",
|
||||
description: "A target with these settings already exists"
|
||||
title: t('targetErrorDuplicate'),
|
||||
description: t('targetErrorDuplicateDescription')
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -265,8 +264,8 @@ export default function ReverseProxyTargets(props: {
|
||||
if (!isIPInSubnet(targetIp, subnet)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid target IP",
|
||||
description: "Target IP must be within the site subnet"
|
||||
title: t('targetWireGuardErrorInvalidIp'),
|
||||
description: t('targetWireGuardErrorInvalidIpDescription')
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -344,8 +343,8 @@ export default function ReverseProxyTargets(props: {
|
||||
updateResource({ stickySession: stickySessionData.stickySession });
|
||||
|
||||
toast({
|
||||
title: "Targets updated",
|
||||
description: "Targets and settings updated successfully"
|
||||
title: t('targetsUpdated'),
|
||||
description: t('targetsUpdatedDescription')
|
||||
});
|
||||
|
||||
setTargetsToRemove([]);
|
||||
@@ -354,10 +353,10 @@ export default function ReverseProxyTargets(props: {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update targets",
|
||||
title: t('targetsErrorUpdate'),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while updating targets"
|
||||
t('targetsErrorUpdateDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
@@ -378,17 +377,17 @@ export default function ReverseProxyTargets(props: {
|
||||
tlsServerName: data.tlsServerName || null
|
||||
});
|
||||
toast({
|
||||
title: "TLS settings updated",
|
||||
description: "Your TLS settings have been updated successfully"
|
||||
title: t('targetTlsUpdate'),
|
||||
description: t('targetTlsUpdateDescription')
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update TLS settings",
|
||||
title: t('targetErrorTlsUpdate'),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while updating TLS settings"
|
||||
t('targetErrorTlsUpdateDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
@@ -407,18 +406,17 @@ export default function ReverseProxyTargets(props: {
|
||||
setHostHeader: data.setHostHeader || null
|
||||
});
|
||||
toast({
|
||||
title: "Proxy settings updated",
|
||||
description:
|
||||
"Your proxy settings have been updated successfully"
|
||||
title: t('proxyUpdated'),
|
||||
description: t('proxyUpdatedDescription')
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update proxy settings",
|
||||
title: t('proxyErrorUpdate'),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while updating proxy settings"
|
||||
t('proxyErrorUpdateDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
@@ -429,7 +427,7 @@ export default function ReverseProxyTargets(props: {
|
||||
const columns: ColumnDef<LocalTarget>[] = [
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: "IP / Hostname",
|
||||
header: t('targetAddr'),
|
||||
cell: ({ row }) => (
|
||||
<Input
|
||||
defaultValue={row.original.ip}
|
||||
@@ -444,7 +442,7 @@ export default function ReverseProxyTargets(props: {
|
||||
},
|
||||
{
|
||||
accessorKey: "port",
|
||||
header: "Port",
|
||||
header: t('targetPort'),
|
||||
cell: ({ row }) => (
|
||||
<Input
|
||||
type="number"
|
||||
@@ -460,7 +458,7 @@ export default function ReverseProxyTargets(props: {
|
||||
},
|
||||
// {
|
||||
// accessorKey: "protocol",
|
||||
// header: "Protocol",
|
||||
// header: t('targetProtocol'),
|
||||
// cell: ({ row }) => (
|
||||
// <Select
|
||||
// defaultValue={row.original.protocol!}
|
||||
@@ -478,7 +476,7 @@ export default function ReverseProxyTargets(props: {
|
||||
// },
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
header: "Enabled",
|
||||
header: t('enabled'),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.original.enabled}
|
||||
@@ -505,7 +503,7 @@ export default function ReverseProxyTargets(props: {
|
||||
variant="outline"
|
||||
onClick={() => removeTarget(row.original.targetId)}
|
||||
>
|
||||
Delete
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -516,7 +514,7 @@ export default function ReverseProxyTargets(props: {
|
||||
if (resource.http) {
|
||||
const methodCol: ColumnDef<LocalTarget> = {
|
||||
accessorKey: "method",
|
||||
header: "Method",
|
||||
header: t('method'),
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.method ?? ""}
|
||||
@@ -564,10 +562,10 @@ export default function ReverseProxyTargets(props: {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Targets Configuration
|
||||
{t('targets')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Set up targets to route traffic to your services
|
||||
{t('targetsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -589,8 +587,8 @@ export default function ReverseProxyTargets(props: {
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="sticky-toggle"
|
||||
label="Enable Sticky Sessions"
|
||||
description="Keep connections on the same backend target for their entire session."
|
||||
label={t('targetStickySessions')}
|
||||
description={t('targetStickySessionsDescription')}
|
||||
defaultChecked={
|
||||
field.value
|
||||
}
|
||||
@@ -621,7 +619,7 @@ export default function ReverseProxyTargets(props: {
|
||||
name="method"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Method</FormLabel>
|
||||
<FormLabel>{t('method')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={
|
||||
@@ -638,7 +636,7 @@ export default function ReverseProxyTargets(props: {
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="method">
|
||||
<SelectValue placeholder="Select method" />
|
||||
<SelectValue placeholder={t('methodSelect')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">
|
||||
@@ -664,7 +662,7 @@ export default function ReverseProxyTargets(props: {
|
||||
name="ip"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative">
|
||||
<FormLabel>IP / Hostname</FormLabel>
|
||||
<FormLabel>{t('targetAddr')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="ip" {...field} />
|
||||
</FormControl>
|
||||
@@ -697,7 +695,7 @@ export default function ReverseProxyTargets(props: {
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormLabel>{t('targetPort')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="port"
|
||||
@@ -716,7 +714,7 @@ export default function ReverseProxyTargets(props: {
|
||||
className="mt-6"
|
||||
disabled={!(watchedIp && watchedPort)}
|
||||
>
|
||||
Add Target
|
||||
{t('targetSubmit')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -760,14 +758,13 @@ export default function ReverseProxyTargets(props: {
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No targets. Add a target using the form.
|
||||
{t('targetNoOne')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
<TableCaption>
|
||||
Adding more than one target above will enable load
|
||||
balancing.
|
||||
{t('targetNoOneDescription')}
|
||||
</TableCaption>
|
||||
</Table>
|
||||
</SettingsSectionBody>
|
||||
@@ -778,7 +775,7 @@ export default function ReverseProxyTargets(props: {
|
||||
disabled={targetsLoading}
|
||||
form="targets-settings-form"
|
||||
>
|
||||
Save Targets
|
||||
{t('targetsSubmit')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
@@ -788,10 +785,10 @@ export default function ReverseProxyTargets(props: {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Secure Connection Configuration
|
||||
{t('targetTlsSettings')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure SSL/TLS settings for your resource
|
||||
{t('targetTlsSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -812,7 +809,7 @@ export default function ReverseProxyTargets(props: {
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="ssl-toggle"
|
||||
label="Enable SSL (https)"
|
||||
label={t('proxyEnableSSL')}
|
||||
defaultChecked={
|
||||
field.value
|
||||
}
|
||||
@@ -841,8 +838,7 @@ export default function ReverseProxyTargets(props: {
|
||||
className="p-0 flex items-center justify-start gap-2 w-full"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Advanced TLS
|
||||
Settings
|
||||
{t('targetTlsSettingsAdvanced')}
|
||||
</p>
|
||||
<div>
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
@@ -862,8 +858,7 @@ export default function ReverseProxyTargets(props: {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
TLS Server Name
|
||||
(SNI)
|
||||
{t('targetTlsSni')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -871,11 +866,7 @@ export default function ReverseProxyTargets(props: {
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The TLS Server
|
||||
Name to use for
|
||||
SNI. Leave empty
|
||||
to use the
|
||||
default.
|
||||
{t('targetTlsSniDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -893,18 +884,17 @@ export default function ReverseProxyTargets(props: {
|
||||
loading={httpsTlsLoading}
|
||||
form="tls-settings-form"
|
||||
>
|
||||
Save Settings
|
||||
{t('targetTlsSubmit')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Additional Proxy Settings
|
||||
{t('proxyAdditional')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure how your resource handles proxy
|
||||
settings
|
||||
{t('proxyAdditionalDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -923,16 +913,13 @@ export default function ReverseProxyTargets(props: {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Custom Host Header
|
||||
{t('proxyCustomHeader')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The host header to set
|
||||
when proxying requests.
|
||||
Leave empty to use the
|
||||
default.
|
||||
{t('proxyCustomHeaderDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -948,7 +935,7 @@ export default function ReverseProxyTargets(props: {
|
||||
loading={proxySettingsLoading}
|
||||
form="proxy-settings-form"
|
||||
>
|
||||
Save Settings
|
||||
{t('targetTlsSubmit')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
@@ -963,8 +950,10 @@ function isIPInSubnet(subnet: string, ip: string): boolean {
|
||||
const [subnetIP, maskBits] = subnet.split("/");
|
||||
const mask = parseInt(maskBits);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
if (mask < 0 || mask > 32) {
|
||||
throw new Error("Invalid subnet mask. Must be between 0 and 32.");
|
||||
throw new Error(t('subnetMaskErrorInvalid'));
|
||||
}
|
||||
|
||||
// Convert IP addresses to binary numbers
|
||||
@@ -981,15 +970,17 @@ function isIPInSubnet(subnet: string, ip: string): boolean {
|
||||
function ipToNumber(ip: string): number {
|
||||
// Validate IP address format
|
||||
const parts = ip.split(".");
|
||||
const t = useTranslations();
|
||||
|
||||
if (parts.length !== 4) {
|
||||
throw new Error("Invalid IP address format");
|
||||
throw new Error(t('ipAddressErrorInvalidFormat'));
|
||||
}
|
||||
|
||||
// Convert IP octets to 32-bit number
|
||||
return parts.reduce((num, octet) => {
|
||||
const oct = parseInt(octet);
|
||||
if (isNaN(oct) || oct < 0 || oct > 255) {
|
||||
throw new Error("Invalid IP address octet");
|
||||
throw new Error(t('ipAddressErrorInvalidOctet'));
|
||||
}
|
||||
return (num << 8) + oct;
|
||||
}, 0);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, use } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -72,6 +73,7 @@ import {
|
||||
} from "@server/lib/validators";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
// Schema for rule validation
|
||||
const addRuleSchema = z.object({
|
||||
@@ -86,17 +88,6 @@ type LocalRule = ArrayElement<ListResourceRulesResponse["rules"]> & {
|
||||
updated?: boolean;
|
||||
};
|
||||
|
||||
enum RuleAction {
|
||||
ACCEPT = "Always Allow",
|
||||
DROP = "Always Deny"
|
||||
}
|
||||
|
||||
enum RuleMatch {
|
||||
PATH = "Path",
|
||||
IP = "IP",
|
||||
CIDR = "IP Range"
|
||||
}
|
||||
|
||||
export default function ResourceRules(props: {
|
||||
params: Promise<{ resourceId: number }>;
|
||||
}) {
|
||||
@@ -109,6 +100,19 @@ export default function ResourceRules(props: {
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
|
||||
const RuleAction = {
|
||||
ACCEPT: t('alwaysAllow'),
|
||||
DROP: t('alwaysDeny')
|
||||
} as const;
|
||||
|
||||
const RuleMatch = {
|
||||
PATH: t('path'),
|
||||
IP: "IP",
|
||||
CIDR: t('ipAddressRange')
|
||||
} as const;
|
||||
|
||||
const addRuleForm = useForm({
|
||||
resolver: zodResolver(addRuleSchema),
|
||||
@@ -132,10 +136,10 @@ export default function ResourceRules(props: {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch rules",
|
||||
title: t('rulesErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while fetching rules"
|
||||
t('rulesErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
@@ -156,8 +160,8 @@ export default function ResourceRules(props: {
|
||||
if (isDuplicate) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Duplicate rule",
|
||||
description: "A rule with these settings already exists"
|
||||
title: t('rulesErrorDuplicate'),
|
||||
description: t('rulesErrorDuplicateDescription')
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -165,8 +169,8 @@ export default function ResourceRules(props: {
|
||||
if (data.match === "CIDR" && !isValidCIDR(data.value)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid CIDR",
|
||||
description: "Please enter a valid CIDR value"
|
||||
title: t('rulesErrorInvalidIpAddressRange'),
|
||||
description: t('rulesErrorInvalidIpAddressRangeDescription')
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -174,8 +178,8 @@ export default function ResourceRules(props: {
|
||||
if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid URL path",
|
||||
description: "Please enter a valid URL path value"
|
||||
title: t('rulesErrorInvalidUrl'),
|
||||
description: t('rulesErrorInvalidUrlDescription')
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -183,8 +187,8 @@ export default function ResourceRules(props: {
|
||||
if (data.match === "IP" && !isValidIP(data.value)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid IP",
|
||||
description: "Please enter a valid IP address"
|
||||
title: t('rulesErrorInvalidIpAddress'),
|
||||
description: t('rulesErrorInvalidIpAddressDescription')
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -239,10 +243,10 @@ export default function ResourceRules(props: {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update rules",
|
||||
title: t('rulesErrorUpdate'),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while updating rules"
|
||||
t('rulesErrorUpdateDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -252,8 +256,8 @@ export default function ResourceRules(props: {
|
||||
updateResource({ applyRules: val });
|
||||
|
||||
toast({
|
||||
title: "Enable Rules",
|
||||
description: "Rule evaluation has been updated"
|
||||
title: t('rulesUpdated'),
|
||||
description: t('rulesUpdatedDescription')
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
@@ -262,11 +266,11 @@ export default function ResourceRules(props: {
|
||||
function getValueHelpText(type: string) {
|
||||
switch (type) {
|
||||
case "CIDR":
|
||||
return "Enter an address in CIDR format (e.g., 103.21.244.0/22)";
|
||||
return t('rulesMatchIpAddressRangeDescription');
|
||||
case "IP":
|
||||
return "Enter an IP address (e.g., 103.21.244.12)";
|
||||
return t('rulesMatchIpAddress');
|
||||
case "PATH":
|
||||
return "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)";
|
||||
return t('rulesMatchUrl');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,8 +289,8 @@ export default function ResourceRules(props: {
|
||||
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid CIDR",
|
||||
description: "Please enter a valid CIDR value"
|
||||
title: t('rulesErrorInvalidIpAddressRange'),
|
||||
description: t('rulesErrorInvalidIpAddressRangeDescription')
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -297,8 +301,8 @@ export default function ResourceRules(props: {
|
||||
) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid URL path",
|
||||
description: "Please enter a valid URL path value"
|
||||
title: t('rulesErrorInvalidUrl'),
|
||||
description: t('rulesErrorInvalidUrlDescription')
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -306,8 +310,8 @@ export default function ResourceRules(props: {
|
||||
if (rule.match === "IP" && !isValidIP(rule.value)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid IP",
|
||||
description: "Please enter a valid IP address"
|
||||
title: t('rulesErrorInvalidIpAddress'),
|
||||
description: t('rulesErrorInvalidIpAddressDescription')
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -316,8 +320,8 @@ export default function ResourceRules(props: {
|
||||
if (rule.priority === undefined) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid Priority",
|
||||
description: "Please enter a valid priority"
|
||||
title: t('rulesErrorInvalidPriority'),
|
||||
description: t('rulesErrorInvalidPriorityDescription')
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -328,8 +332,8 @@ export default function ResourceRules(props: {
|
||||
if (priorities.length !== new Set(priorities).size) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Duplicate Priorities",
|
||||
description: "Please enter unique priorities"
|
||||
title: t('rulesErrorDuplicatePriority'),
|
||||
description: t('rulesErrorDuplicatePriorityDescription')
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -368,8 +372,8 @@ export default function ResourceRules(props: {
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Rules updated",
|
||||
description: "Rules updated successfully"
|
||||
title: t('ruleUpdated'),
|
||||
description: t('ruleUpdatedDescription')
|
||||
});
|
||||
|
||||
setRulesToRemove([]);
|
||||
@@ -378,10 +382,10 @@ export default function ResourceRules(props: {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Operation failed",
|
||||
title: t('ruleErrorUpdate'),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred during the save operation"
|
||||
t('ruleErrorUpdateDescription')
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -399,7 +403,7 @@ export default function ResourceRules(props: {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Priority
|
||||
{t('rulesPriority')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -419,8 +423,8 @@ export default function ResourceRules(props: {
|
||||
if (!parsed.data) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid IP",
|
||||
description: "Please enter a valid priority"
|
||||
title: t('rulesErrorInvalidIpAddress'), // correct priority or IP?
|
||||
description: t('rulesErrorInvalidPriorityDescription')
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -435,7 +439,7 @@ export default function ResourceRules(props: {
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
header: "Action",
|
||||
header: t('rulesAction'),
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.action}
|
||||
@@ -457,7 +461,7 @@ export default function ResourceRules(props: {
|
||||
},
|
||||
{
|
||||
accessorKey: "match",
|
||||
header: "Match Type",
|
||||
header: t('rulesMatchType'),
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.match}
|
||||
@@ -478,7 +482,7 @@ export default function ResourceRules(props: {
|
||||
},
|
||||
{
|
||||
accessorKey: "value",
|
||||
header: "Value",
|
||||
header: t('value'),
|
||||
cell: ({ row }) => (
|
||||
<Input
|
||||
defaultValue={row.original.value}
|
||||
@@ -493,7 +497,7 @@ export default function ResourceRules(props: {
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
header: "Enabled",
|
||||
header: t('enabled'),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.original.enabled}
|
||||
@@ -511,7 +515,7 @@ export default function ResourceRules(props: {
|
||||
variant="outline"
|
||||
onClick={() => removeRule(row.original.ruleId)}
|
||||
>
|
||||
Delete
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -541,46 +545,40 @@ export default function ResourceRules(props: {
|
||||
<SettingsContainer>
|
||||
<Alert className="hidden md:block">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">About Rules</AlertTitle>
|
||||
<AlertTitle className="font-semibold">{t('rulesAbout')}</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<div className="space-y-1 mb-4">
|
||||
<p>
|
||||
Rules allow you to control access to your resource
|
||||
based on a set of criteria. You can create rules to
|
||||
allow or deny access based on IP address or URL
|
||||
path.
|
||||
{t('rulesAboutDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<InfoSections cols={2}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Actions</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t('rulesActions')}</InfoSectionTitle>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="text-green-500 w-4 h-4" />
|
||||
Always Allow: Bypass all authentication
|
||||
methods
|
||||
{t('rulesActionAlwaysAllow')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<X className="text-red-500 w-4 h-4" />
|
||||
Always Deny: Block all requests; no
|
||||
authentication can be attempted
|
||||
{t('rulesActionAlwaysDeny')}
|
||||
</li>
|
||||
</ul>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Matching Criteria
|
||||
{t('rulesMatchCriteria')}
|
||||
</InfoSectionTitle>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li className="flex items-center gap-2">
|
||||
Match a specific IP address
|
||||
{t('rulesMatchCriteriaIpAddress')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
Match a range of IP addresses in CIDR
|
||||
notation
|
||||
{t('rulesMatchCriteriaIpAddressRange')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
Match a URL path or pattern
|
||||
{t('rulesMatchCriteriaUrl')}
|
||||
</li>
|
||||
</ul>
|
||||
</InfoSection>
|
||||
@@ -590,15 +588,15 @@ export default function ResourceRules(props: {
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>Enable Rules</SettingsSectionTitle>
|
||||
<SettingsSectionTitle>{t('rulesEnable')}</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Enable or disable rule evaluation for this resource
|
||||
{t('rulesEnableDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SwitchInput
|
||||
id="rules-toggle"
|
||||
label="Enable Rules"
|
||||
label={t('rulesEnable')}
|
||||
defaultChecked={rulesEnabled}
|
||||
onCheckedChange={async (val) => {
|
||||
await saveApplyRules(val);
|
||||
@@ -610,10 +608,10 @@ export default function ResourceRules(props: {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Resource Rules Configuration
|
||||
{t('rulesResource')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure rules to control access to your resource
|
||||
{t('rulesResourceDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -628,7 +626,7 @@ export default function ResourceRules(props: {
|
||||
name="action"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Action</FormLabel>
|
||||
<FormLabel>{t('rulesAction')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
@@ -658,7 +656,7 @@ export default function ResourceRules(props: {
|
||||
name="match"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Match Type</FormLabel>
|
||||
<FormLabel>{t('rulesMatchType')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
@@ -694,7 +692,7 @@ export default function ResourceRules(props: {
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-0 mb-2">
|
||||
<InfoPopup
|
||||
text="Value"
|
||||
text={t('value')}
|
||||
info={
|
||||
getValueHelpText(
|
||||
addRuleForm.watch(
|
||||
@@ -716,7 +714,7 @@ export default function ResourceRules(props: {
|
||||
className="mb-2"
|
||||
disabled={!rulesEnabled}
|
||||
>
|
||||
Add Rule
|
||||
{t('ruleSubmit')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -759,13 +757,13 @@ export default function ResourceRules(props: {
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No rules. Add a rule using the form.
|
||||
{t('rulesNoOne')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
<TableCaption>
|
||||
Rules are evaluated by priority in ascending order.
|
||||
{t('rulesOrder')}
|
||||
</TableCaption>
|
||||
</Table>
|
||||
</SettingsSectionBody>
|
||||
@@ -775,7 +773,7 @@ export default function ResourceRules(props: {
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Save Rules
|
||||
{t('rulesSubmit')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -62,6 +62,7 @@ import { cn } from "@app/lib/cn";
|
||||
import { SquareArrowOutUpRight } from "lucide-react";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const baseResourceFormSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
@@ -104,6 +105,7 @@ export default function Page() {
|
||||
const api = createApiClient({ env });
|
||||
const { orgId } = useParams();
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [loadingPage, setLoadingPage] = useState(true);
|
||||
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
||||
@@ -117,15 +119,13 @@ export default function Page() {
|
||||
const resourceTypes: ReadonlyArray<ResourceTypeOption> = [
|
||||
{
|
||||
id: "http",
|
||||
title: "HTTPS Resource",
|
||||
description:
|
||||
"Proxy requests to your app over HTTPS using a subdomain or base domain."
|
||||
title: t('resourceHTTP'),
|
||||
description: t('resourceHTTPDescription')
|
||||
},
|
||||
{
|
||||
id: "raw",
|
||||
title: "Raw TCP/UDP Resource",
|
||||
description:
|
||||
"Proxy requests to your app over TCP/UDP using a port number.",
|
||||
title: t('resourceRaw'),
|
||||
description: t('resourceRawDescription'),
|
||||
disabled: !env.flags.allowRawResources
|
||||
}
|
||||
];
|
||||
@@ -199,10 +199,10 @@ export default function Page() {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating resource",
|
||||
title: t('resourceErrorCreate'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred when creating the resource"
|
||||
t('resourceErrorCreateDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -219,11 +219,11 @@ export default function Page() {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error creating resource:", e);
|
||||
console.error(t('resourceErrorCreateMessage'), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating resource",
|
||||
description: "An unexpected error occurred"
|
||||
title: t('resourceErrorCreate'),
|
||||
description:t('resourceErrorCreateMessageDescription')
|
||||
});
|
||||
}
|
||||
|
||||
@@ -242,10 +242,10 @@ export default function Page() {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error fetching sites",
|
||||
title: t('sitesErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred when fetching the sites"
|
||||
t('sitesErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -270,10 +270,10 @@ export default function Page() {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error fetching domains",
|
||||
title: t('domainsErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred when fetching the domains"
|
||||
t('domainsErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -300,8 +300,8 @@ export default function Page() {
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title="Create Resource"
|
||||
description="Follow the steps below to create a new resource"
|
||||
title={t('resourceCreate')}
|
||||
description={t('resourceCreateDescription')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -309,7 +309,7 @@ export default function Page() {
|
||||
router.push(`/${orgId}/settings/resources`);
|
||||
}}
|
||||
>
|
||||
See All Resources
|
||||
{t('resourceSeeAll')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -320,7 +320,7 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Resource Information
|
||||
{t('resourceInfo')}
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -336,7 +336,7 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name
|
||||
{t('name')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -345,9 +345,7 @@ export default function Page() {
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the
|
||||
display name for
|
||||
the resource.
|
||||
{t('resourceNameDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -359,7 +357,7 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>
|
||||
Site
|
||||
{t('site')}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
@@ -384,19 +382,17 @@ export default function Page() {
|
||||
field.value
|
||||
)
|
||||
?.name
|
||||
: "Select site"}
|
||||
: t('siteSelect')}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search site" />
|
||||
<CommandInput placeholder={t('siteSearch')} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No
|
||||
site
|
||||
found.
|
||||
{t('siteNotFound')}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sites.map(
|
||||
@@ -437,10 +433,7 @@ export default function Page() {
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This site will
|
||||
provide
|
||||
connectivity to
|
||||
the resource.
|
||||
{t('siteSelectionDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -454,11 +447,10 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Resource Type
|
||||
{t('resourceType')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Determine how you want to access your
|
||||
resource
|
||||
{t('resourceTypeDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -480,11 +472,10 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
HTTPS Settings
|
||||
{t('resourceHTTPSSettings')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure how your resource will be
|
||||
accessed over HTTPS
|
||||
{t('resourceHTTPSSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -506,8 +497,7 @@ export default function Page() {
|
||||
}) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Domain
|
||||
Type
|
||||
{t('domainType')}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={
|
||||
@@ -531,11 +521,10 @@ export default function Page() {
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="subdomain">
|
||||
Subdomain
|
||||
{t('subdomain')}
|
||||
</SelectItem>
|
||||
<SelectItem value="basedomain">
|
||||
Base
|
||||
Domain
|
||||
{t('baseDomain')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -550,7 +539,7 @@ export default function Page() {
|
||||
) && (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Subdomain
|
||||
{t('subdomain')}
|
||||
</FormLabel>
|
||||
<div className="flex space-x-0">
|
||||
<div className="w-1/2">
|
||||
@@ -629,10 +618,7 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
<FormDescription>
|
||||
The subdomain
|
||||
where your
|
||||
resource will be
|
||||
accessible.
|
||||
{t('subdomnainDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -650,8 +636,7 @@ export default function Page() {
|
||||
}) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Base
|
||||
Domain
|
||||
{t('baseDomain')}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
@@ -702,11 +687,10 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
TCP/UDP Settings
|
||||
{t('resourceRawSettings')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure how your resource will be
|
||||
accessed over TCP/UDP
|
||||
{t('resourceRawSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -724,7 +708,7 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Protocol
|
||||
{t('protocol')}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
@@ -734,7 +718,7 @@ export default function Page() {
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a protocol" />
|
||||
<SelectValue placeholder={t('protocolSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -759,7 +743,7 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Port Number
|
||||
{t('resourcePortNumber')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -787,10 +771,7 @@ export default function Page() {
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
The external
|
||||
port number
|
||||
to proxy
|
||||
requests.
|
||||
{t('resourcePortNumberDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -810,7 +791,7 @@ export default function Page() {
|
||||
router.push(`/${orgId}/settings/resources`)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -827,7 +808,7 @@ export default function Page() {
|
||||
}}
|
||||
loading={createLoading}
|
||||
>
|
||||
Create Resource
|
||||
{t('resourceCreate')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
@@ -836,17 +817,17 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Configuration Snippets
|
||||
{t('resourceConfig')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Copy and paste these configuration snippets to set up your TCP/UDP resource
|
||||
{t('resourceConfigDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
Traefik: Add Entrypoints
|
||||
{t('resourceAddEntrypoints')}
|
||||
</h3>
|
||||
<CopyTextBox
|
||||
text={`entryPoints:
|
||||
@@ -858,7 +839,7 @@ export default function Page() {
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
Gerbil: Expose Ports in Docker Compose
|
||||
{t('resourceExposePorts')}
|
||||
</h3>
|
||||
<CopyTextBox
|
||||
text={`ports:
|
||||
@@ -874,7 +855,7 @@ export default function Page() {
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span>
|
||||
Learn how to configure TCP/UDP resources
|
||||
{t('resourceLearnRaw')}
|
||||
</span>
|
||||
<SquareArrowOutUpRight size={14} />
|
||||
</Link>
|
||||
@@ -890,7 +871,7 @@ export default function Page() {
|
||||
router.push(`/${orgId}/settings/resources`)
|
||||
}
|
||||
>
|
||||
Back to Resources
|
||||
{t('resourceBack')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -900,7 +881,7 @@ export default function Page() {
|
||||
)
|
||||
}
|
||||
>
|
||||
Go to Resource
|
||||
{t('resourceGoTo')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { cache } from "react";
|
||||
import { GetOrgResponse } from "@server/routers/org";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import ResourcesSplashCard from "./ResourcesSplashCard";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
type ResourcesPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
@@ -18,6 +19,8 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
let resources: ListResourcesResponse["resources"] = [];
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
|
||||
@@ -51,8 +54,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||
name: resource.name,
|
||||
orgId: params.orgId,
|
||||
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
|
||||
site: resource.siteName || "None",
|
||||
siteId: resource.siteId || "Unknown",
|
||||
site: resource.siteName || t('none'),
|
||||
siteId: resource.siteId || t('unknown'),
|
||||
protocol: resource.protocol,
|
||||
proxyPort: resource.proxyPort,
|
||||
http: resource.http,
|
||||
@@ -73,8 +76,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||
{/* <ResourcesSplashCard /> */}
|
||||
|
||||
<SettingsSectionTitle
|
||||
title="Manage Resources"
|
||||
description="Create secure proxies to your private applications"
|
||||
title={t('resourceTitle')}
|
||||
description={t('resourceDescription')}
|
||||
/>
|
||||
|
||||
<OrgProvider org={org}>
|
||||
|
||||
@@ -15,6 +15,7 @@ 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;
|
||||
@@ -37,37 +38,37 @@ export default function AccessTokenSection({
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start space-x-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your access token can be passed in two ways: as a query
|
||||
parameter or in the request headers. These must be passed
|
||||
from the client on every request for authenticated access.
|
||||
{t('shareTokenDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="token" className="w-full mt-4">
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="token">Access Token</TabsTrigger>
|
||||
<TabsTrigger value="usage">Usage Examples</TabsTrigger>
|
||||
<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">Token ID</div>
|
||||
<div className="font-bold">{t('tokenId')}</div>
|
||||
<CopyToClipboard text={tokenId} isLink={false} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="font-bold">Token</div>
|
||||
<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">Request Headers</h3>
|
||||
<h3 className="text-sm font-medium">{t('requestHeades')}</h3>
|
||||
<CopyTextBox
|
||||
text={`${env.server.resourceAccessTokenHeadersId}: ${tokenId}
|
||||
${env.server.resourceAccessTokenHeadersToken}: ${token}`}
|
||||
@@ -75,7 +76,7 @@ ${env.server.resourceAccessTokenHeadersToken}: ${token}`}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Query Parameter</h3>
|
||||
<h3 className="text-sm font-medium">{t('queryParameter')}</h3>
|
||||
<CopyTextBox
|
||||
text={`${resourceUrl}?${env.server.resourceAccessTokenParam}=${tokenId}.${token}`}
|
||||
/>
|
||||
@@ -84,21 +85,17 @@ ${env.server.resourceAccessTokenHeadersToken}: ${token}`}
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Important Note
|
||||
{t('importantNote')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
For security reasons, using headers is recommended
|
||||
over query parameters when possible, as query
|
||||
parameters may be logged in server logs or browser
|
||||
history.
|
||||
{t('shareImportantDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="text-sm text-muted-foreground mt-4">
|
||||
Keep your access token secure. Do not share it in publicly
|
||||
accessible areas or client-side code.
|
||||
{t('shareTokenSecurety')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
CollapsibleTrigger
|
||||
} from "@app/components/ui/collapsible";
|
||||
import AccessTokenSection from "./AccessTokenUsage";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type FormProps = {
|
||||
open: boolean;
|
||||
@@ -73,15 +74,6 @@ type FormProps = {
|
||||
onCreated?: (result: ShareLinkRow) => void;
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
resourceId: z.number({ message: "Please select a resource" }),
|
||||
resourceName: z.string(),
|
||||
resourceUrl: z.string(),
|
||||
timeUnit: z.string(),
|
||||
timeValue: z.coerce.number().int().positive().min(1),
|
||||
title: z.string().optional()
|
||||
});
|
||||
|
||||
export default function CreateShareLinkForm({
|
||||
open,
|
||||
setOpen,
|
||||
@@ -99,6 +91,7 @@ export default function CreateShareLinkForm({
|
||||
const [neverExpire, setNeverExpire] = useState(false);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
const [resources, setResources] = useState<
|
||||
{
|
||||
@@ -109,13 +102,22 @@ export default function CreateShareLinkForm({
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
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: "Minutes" },
|
||||
{ unit: "hours", name: "Hours" },
|
||||
{ unit: "days", name: "Days" },
|
||||
{ unit: "weeks", name: "Weeks" },
|
||||
{ unit: "months", name: "Months" },
|
||||
{ unit: "years", name: "Years" }
|
||||
{ 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>>({
|
||||
@@ -141,10 +143,10 @@ export default function CreateShareLinkForm({
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch resources",
|
||||
title: t('shareErrorFetchResource'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while fetching the resources"
|
||||
t('shareErrorFetchResourceDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -201,17 +203,17 @@ export default function CreateShareLinkForm({
|
||||
validForSeconds: neverExpire ? undefined : timeInSeconds,
|
||||
title:
|
||||
values.title ||
|
||||
`${values.resourceName || "Resource" + values.resourceId} Share Link`
|
||||
t('shareLink', {resource: (values.resourceName || "Resource" + values.resourceId)})
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to create share link",
|
||||
title: t('shareErrorCreate'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while creating the share link"
|
||||
t('shareErrorCreateDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -260,9 +262,9 @@ export default function CreateShareLinkForm({
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Create Shareable Link</CredenzaTitle>
|
||||
<CredenzaTitle>{t('shareCreate')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Anyone with this link can access the resource
|
||||
{t('shareCreateDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
@@ -280,7 +282,7 @@ export default function CreateShareLinkForm({
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>
|
||||
Resource
|
||||
{t('resource')}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -298,19 +300,17 @@ export default function CreateShareLinkForm({
|
||||
? getSelectedResourceName(
|
||||
field.value
|
||||
)
|
||||
: "Select resource"}
|
||||
: t('resourceSelect')}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search resources" />
|
||||
<CommandInput placeholder={t('resourceSearch')} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No
|
||||
resources
|
||||
found
|
||||
{t('resourcesNotFound')}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{resources.map(
|
||||
@@ -366,7 +366,7 @@ export default function CreateShareLinkForm({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Title (optional)
|
||||
{t('shareTitleOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
@@ -378,7 +378,7 @@ export default function CreateShareLinkForm({
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Expire In</FormLabel>
|
||||
<FormLabel>{t('expireIn')}</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -393,7 +393,7 @@ export default function CreateShareLinkForm({
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select duration" />
|
||||
<SelectValue placeholder={t('selectDuration')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -455,18 +455,12 @@ export default function CreateShareLinkForm({
|
||||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Never expire
|
||||
{t('neverExpire')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Expiration time is how long the
|
||||
link will be usable and provide
|
||||
access to the resource. After
|
||||
this time, the link will no
|
||||
longer work, and users who used
|
||||
this link will lose access to
|
||||
the resource.
|
||||
{t('shareExpireDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
@@ -475,12 +469,10 @@ export default function CreateShareLinkForm({
|
||||
{link && (
|
||||
<div className="max-w-md space-y-4">
|
||||
<p>
|
||||
You will only be able to see this link
|
||||
once. Make sure to copy it.
|
||||
{t('shareSeeOnce')}
|
||||
</p>
|
||||
<p>
|
||||
Anyone with this link can access the
|
||||
resource. Share it with care.
|
||||
{t('shareAccessHint')}
|
||||
</p>
|
||||
|
||||
<div className="h-[250px] w-full mx-auto flex items-center justify-center">
|
||||
@@ -506,12 +498,12 @@ export default function CreateShareLinkForm({
|
||||
className="p-0 flex items-center justify-between w-full"
|
||||
>
|
||||
<h4 className="text-sm font-semibold">
|
||||
See Access Token Usage
|
||||
{t('shareTokenUsage')}
|
||||
</h4>
|
||||
<div>
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
Toggle
|
||||
{t('toggle')}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
@@ -541,7 +533,7 @@ export default function CreateShareLinkForm({
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -549,7 +541,7 @@ export default function CreateShareLinkForm({
|
||||
loading={loading}
|
||||
disabled={link !== null || loading}
|
||||
>
|
||||
Create Link
|
||||
{t('createLink')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
||||
@@ -4,6 +4,7 @@ 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>[];
|
||||
@@ -16,15 +17,18 @@ export function ShareLinksDataTable<TData, TValue>({
|
||||
data,
|
||||
createShareLink
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="Share Links"
|
||||
searchPlaceholder="Search share links..."
|
||||
title={t('shareLinks')}
|
||||
searchPlaceholder={t('shareSearch')}
|
||||
searchColumn="name"
|
||||
onAdd={createShareLink}
|
||||
addButtonText="Create Share Link"
|
||||
addButtonText={t('shareCreate')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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);
|
||||
@@ -22,6 +23,8 @@ export const ShareableLinksSplash = () => {
|
||||
localStorage.setItem(key, "true");
|
||||
};
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
if (isDismissed) {
|
||||
return null;
|
||||
}
|
||||
@@ -31,7 +34,7 @@ export const ShareableLinksSplash = () => {
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-2 right-2 p-2"
|
||||
aria-label="Dismiss"
|
||||
aria-label={t('dismiss')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -39,26 +42,23 @@ export const ShareableLinksSplash = () => {
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Link className="text-blue-500" />
|
||||
Shareable Links
|
||||
{t('share')}
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
Create shareable links to your resources. Links provide
|
||||
temporary or unlimited access to your resource. You can
|
||||
configure the expiration duration of the link when you
|
||||
create one.
|
||||
{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" />
|
||||
Easy to create and share
|
||||
{t('shareEasyCreate')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Clock className="text-yellow-500 w-4 h-4" />
|
||||
Configurable expiration duration
|
||||
{t('shareConfigurableExpirationDuration')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Lock className="text-red-500 w-4 h-4" />
|
||||
Secure and revocable
|
||||
{t('shareSecureAndRevocable')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ import { ListAccessTokensResponse } from "@server/routers/accessToken";
|
||||
import moment from "moment";
|
||||
import CreateShareLinkForm from "./CreateShareLinkForm";
|
||||
import { constructShareLink } from "@app/lib/shareLinks";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type ShareLinkRow = {
|
||||
accessTokenId: string;
|
||||
@@ -54,6 +55,7 @@ export default function ShareLinksTable({
|
||||
orgId
|
||||
}: ShareLinksTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
@@ -67,10 +69,10 @@ export default function ShareLinksTable({
|
||||
async function deleteSharelink(id: string) {
|
||||
await api.delete(`/access-token/${id}`).catch((e) => {
|
||||
toast({
|
||||
title: "Failed to delete link",
|
||||
title: t('shareErrorDelete'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred deleting link"
|
||||
t('shareErrorDeleteMessage')
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -79,8 +81,8 @@ export default function ShareLinksTable({
|
||||
setRows(newRows);
|
||||
|
||||
toast({
|
||||
title: "Link deleted",
|
||||
description: "The link has been deleted"
|
||||
title: t('shareDeleted'),
|
||||
description: t('shareDeletedDescription')
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,7 +104,7 @@ export default function ShareLinksTable({
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Open menu
|
||||
{t('openMenu')}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -116,7 +118,7 @@ export default function ShareLinksTable({
|
||||
}}
|
||||
>
|
||||
<button className="text-red-500">
|
||||
Delete
|
||||
{t('delete')}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -136,7 +138,7 @@ export default function ShareLinksTable({
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Resource
|
||||
{t('resource')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -164,7 +166,7 @@ export default function ShareLinksTable({
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Title
|
||||
{t('title')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -243,7 +245,7 @@ export default function ShareLinksTable({
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Created
|
||||
{t('created')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -263,7 +265,7 @@ export default function ShareLinksTable({
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Expires
|
||||
{t('expires')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -273,7 +275,7 @@ export default function ShareLinksTable({
|
||||
if (r.expiresAt) {
|
||||
return moment(r.expiresAt).format("lll");
|
||||
}
|
||||
return "Never";
|
||||
return t('never');
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -286,7 +288,7 @@ export default function ShareLinksTable({
|
||||
deleteSharelink(row.original.accessTokenId)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { ListAccessTokensResponse } from "@server/routers/accessToken";
|
||||
import ShareLinksTable, { ShareLinkRow } from "./ShareLinksTable";
|
||||
import ShareableLinksSplash from "./ShareLinksSplash";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
type ShareLinksPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
@@ -51,13 +52,15 @@ export default async function ShareLinksPage(props: ShareLinksPageProps) {
|
||||
(token) => ({ ...token }) as ShareLinkRow
|
||||
);
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* <ShareableLinksSplash /> */}
|
||||
|
||||
<SettingsSectionTitle
|
||||
title="Manage Share Links"
|
||||
description="Create shareable links to grant temporary or permanent access to your resources"
|
||||
title={t('shareTitle')}
|
||||
description={t('shareDescription')}
|
||||
/>
|
||||
|
||||
<OrgProvider org={org}>
|
||||
|
||||
@@ -50,25 +50,7 @@ import {
|
||||
CollapsibleTrigger
|
||||
} from "@app/components/ui/collapsible";
|
||||
import LoaderPlaceholder from "@app/components/PlaceHolderLoader";
|
||||
|
||||
const createSiteFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: "Name must be at least 2 characters."
|
||||
})
|
||||
.max(30, {
|
||||
message: "Name must not be longer than 30 characters."
|
||||
}),
|
||||
method: z.enum(["wireguard", "newt", "local"])
|
||||
});
|
||||
|
||||
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
|
||||
|
||||
const defaultValues: Partial<CreateSiteFormValues> = {
|
||||
name: "",
|
||||
method: "newt"
|
||||
};
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type CreateSiteFormProps = {
|
||||
onCreate?: (site: SiteRow) => void;
|
||||
@@ -96,6 +78,27 @@ export default function CreateSiteForm({
|
||||
privateKey: string;
|
||||
} | null>(null);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const createSiteFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: t('nameMin', {len: 2})
|
||||
})
|
||||
.max(30, {
|
||||
message: t('nameMax', {len: 30})
|
||||
}),
|
||||
method: z.enum(["wireguard", "newt", "local"])
|
||||
});
|
||||
|
||||
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
|
||||
|
||||
const defaultValues: Partial<CreateSiteFormValues> = {
|
||||
name: "",
|
||||
method: "newt"
|
||||
};
|
||||
|
||||
const [siteDefaults, setSiteDefaults] =
|
||||
useState<PickSiteDefaultsResponse | null>(null);
|
||||
|
||||
@@ -169,8 +172,8 @@ export default function CreateSiteForm({
|
||||
if (!keypair || !siteDefaults) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating site",
|
||||
description: "Key pair or site defaults not found"
|
||||
title: t('siteErrorCreate'),
|
||||
description: t('siteErrorCreateKeyPair')
|
||||
});
|
||||
setLoading?.(false);
|
||||
setIsLoading(false);
|
||||
@@ -188,8 +191,8 @@ export default function CreateSiteForm({
|
||||
if (!siteDefaults) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating site",
|
||||
description: "Site defaults not found"
|
||||
title: t('siteErrorCreate'),
|
||||
description: t('siteErrorCreateDefaults')
|
||||
});
|
||||
setLoading?.(false);
|
||||
setIsLoading(false);
|
||||
@@ -212,7 +215,7 @@ export default function CreateSiteForm({
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating site",
|
||||
title: t('siteErrorCreate'),
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
});
|
||||
@@ -227,11 +230,11 @@ export default function CreateSiteForm({
|
||||
address: data.address?.split("/")[0],
|
||||
mbIn:
|
||||
data.type == "wireguard" || data.type == "newt"
|
||||
? "0 MB"
|
||||
? t('megabytes', {count: 0})
|
||||
: "-",
|
||||
mbOut:
|
||||
data.type == "wireguard" || data.type == "newt"
|
||||
? "0 MB"
|
||||
? t('megabytes', {count: 0})
|
||||
: "-",
|
||||
orgId: orgId as string,
|
||||
type: data.type as any,
|
||||
@@ -286,13 +289,13 @@ PersistentKeepalive = 5`
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>{t('name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoComplete="off" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the display name for the site.
|
||||
{t('siteNameDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -302,18 +305,18 @@ PersistentKeepalive = 5`
|
||||
name="method"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Method</FormLabel>
|
||||
<FormLabel>{t('method')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select method" />
|
||||
<SelectValue placeholder={t('methodSelect')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local">
|
||||
Local
|
||||
{t('local')}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="newt"
|
||||
@@ -332,7 +335,7 @@ PersistentKeepalive = 5`
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is how you will expose connections.
|
||||
{t('siteMethodDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -346,7 +349,7 @@ PersistentKeepalive = 5`
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span>
|
||||
Learn how to install Newt on your system
|
||||
{t('siteLearnNewt')}
|
||||
</span>
|
||||
<SquareArrowOutUpRight size={14} />
|
||||
</Link>
|
||||
@@ -357,13 +360,12 @@ PersistentKeepalive = 5`
|
||||
<>
|
||||
<CopyTextBox text={wgConfig} />
|
||||
<span className="text-sm text-muted-foreground mt-2">
|
||||
You will only be able to see the
|
||||
configuration once.
|
||||
{t('siteSeeConfigOnce')}
|
||||
</span>
|
||||
</>
|
||||
) : form.watch("method") === "wireguard" &&
|
||||
isLoading ? (
|
||||
<p>Loading WireGuard configuration...</p>
|
||||
<p>{t('siteLoadWGConfig')}</p>
|
||||
) : form.watch("method") === "newt" && siteDefaults ? (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
@@ -379,8 +381,7 @@ PersistentKeepalive = 5`
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
You will only be able to see the
|
||||
configuration once.
|
||||
{t('siteSeeConfigOnce')}
|
||||
</span>
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<CollapsibleTrigger asChild>
|
||||
@@ -390,13 +391,12 @@ PersistentKeepalive = 5`
|
||||
className="p-0 flex items-center justify-between w-full"
|
||||
>
|
||||
<h4 className="text-sm font-semibold">
|
||||
Expand for Docker
|
||||
Deployment Details
|
||||
{t('siteDocker')}
|
||||
</h4>
|
||||
<div>
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
Toggle
|
||||
{t('toggle')}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
@@ -404,7 +404,7 @@ PersistentKeepalive = 5`
|
||||
</div>
|
||||
<CollapsibleContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<b>Docker Compose</b>
|
||||
<b>{t('dockerCompose')}</b>
|
||||
<CopyTextBox
|
||||
text={
|
||||
newtConfigDockerCompose
|
||||
@@ -413,7 +413,7 @@ PersistentKeepalive = 5`
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<b>Docker Run</b>
|
||||
<b>{t('dockerRun')}</b>
|
||||
|
||||
<CopyTextBox
|
||||
text={newtConfigDockerRun}
|
||||
@@ -434,7 +434,7 @@ PersistentKeepalive = 5`
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span> Local sites do not tunnel, learn more</span>
|
||||
<span>{t('siteLearnLocal')}</span>
|
||||
<SquareArrowOutUpRight size={14} />
|
||||
</Link>
|
||||
)}
|
||||
@@ -451,7 +451,7 @@ PersistentKeepalive = 5`
|
||||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
I have copied the config
|
||||
{t('siteConfirmCopy')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@app/components/Credenza";
|
||||
import { SiteRow } from "./SitesTable";
|
||||
import CreateSiteForm from "./CreateSiteForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type CreateSiteFormProps = {
|
||||
open: boolean;
|
||||
@@ -30,6 +31,7 @@ export default function CreateSiteFormModal({
|
||||
}: CreateSiteFormProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -42,9 +44,9 @@ export default function CreateSiteFormModal({
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Create Site</CredenzaTitle>
|
||||
<CredenzaTitle>{t('siteCreate')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Create a new site to start connecting your resources
|
||||
{t('siteCreateDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
@@ -59,7 +61,7 @@ export default function CreateSiteFormModal({
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -70,7 +72,7 @@ export default function CreateSiteFormModal({
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Create Site
|
||||
{t('siteCreate')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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>[];
|
||||
@@ -14,15 +15,18 @@ export function SitesDataTable<TData, TValue>({
|
||||
data,
|
||||
createSite
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="Sites"
|
||||
searchPlaceholder="Search sites..."
|
||||
title={t('sites')}
|
||||
searchPlaceholder={t('searchSitesProgress')}
|
||||
searchColumn="name"
|
||||
onAdd={createSite}
|
||||
addButtonText="Add Site"
|
||||
addButtonText={t('siteAdd')}
|
||||
defaultSort={{
|
||||
id: "name",
|
||||
desc: false
|
||||
|
||||
@@ -5,11 +5,13 @@ 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 { useTranslations } from 'next-intl';
|
||||
|
||||
export const SitesSplashCard = () => {
|
||||
const [isDismissed, setIsDismissed] = useState(true);
|
||||
|
||||
const key = "sites-splash-card-dismissed";
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
const dismissed = localStorage.getItem(key);
|
||||
@@ -34,7 +36,7 @@ export const SitesSplashCard = () => {
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-2 right-2 p-2"
|
||||
aria-label="Dismiss"
|
||||
aria-label={t('dismiss')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -42,22 +44,19 @@ export const SitesSplashCard = () => {
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Globe className="text-blue-500" />
|
||||
Newt (Recommended)
|
||||
Newt ({t('recommended')})
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
For the best user experience, use Newt. It uses
|
||||
WireGuard under the hood and allows you to address your
|
||||
private resources by their LAN address on your private
|
||||
network from within the Pangolin dashboard.
|
||||
{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" />
|
||||
Runs in Docker
|
||||
{t('siteRunsInDocker')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Server className="text-green-500 w-4 h-4" />
|
||||
Runs in shell on macOS, Linux, and Windows
|
||||
{t('siteRunsInShell')}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -71,7 +70,7 @@ export const SitesSplashCard = () => {
|
||||
className="w-full flex items-center"
|
||||
variant="secondary"
|
||||
>
|
||||
Install Newt{" "}
|
||||
{t('siteInstallNewt')}{" "}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -79,20 +78,19 @@ export const SitesSplashCard = () => {
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
Basic WireGuard
|
||||
{t('siteWg')}
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
Use any WireGuard client to connect. You will have to
|
||||
address your internal resources using the peer IP.
|
||||
{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" />
|
||||
Compatible with all WireGuard clients
|
||||
{t('siteWgCompatibleAllClients')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Server className="text-purple-500 w-4 h-4" />
|
||||
Manual configuration required
|
||||
{t('siteWgManualConfigurationRequired')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import CreateSiteFormModal from "./CreateSiteModal";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { parseDataSize } from '@app/lib/dataSize';
|
||||
|
||||
export type SiteRow = {
|
||||
@@ -54,15 +55,16 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
const [rows, setRows] = useState<SiteRow[]>(sites);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
const deleteSite = (siteId: number) => {
|
||||
api.delete(`/site/${siteId}`)
|
||||
.catch((e) => {
|
||||
console.error("Error deleting site", e);
|
||||
console.error(t('siteErrorDelete'), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error deleting site",
|
||||
description: formatAxiosError(e, "Error deleting site")
|
||||
title: t('siteErrorDelete'),
|
||||
description: formatAxiosError(e, t('siteErrorDelete'))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
@@ -96,7 +98,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
View settings
|
||||
{t('viewSettings')}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
@@ -105,7 +107,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
<span className="text-red-500">{t('delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -122,7 +124,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
{t('name')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -138,7 +140,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Online
|
||||
{t('online')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -153,14 +155,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
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>Online</span>
|
||||
<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>Offline</span>
|
||||
<span>{t('offline')}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -179,7 +181,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Site
|
||||
{t('site')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -195,7 +197,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Data In
|
||||
{t('dataIn')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -213,7 +215,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Data Out
|
||||
{t('dataOut')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -231,7 +233,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Connection Type
|
||||
{t('connectionType')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -258,7 +260,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
if (originalRow.type === "local") {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Local</span>
|
||||
<span>{t('local')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -290,7 +292,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
||||
>
|
||||
<Button variant={"outlinePrimary"} className="ml-2">
|
||||
Edit
|
||||
{t('edit')}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -312,30 +314,22 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to remove the site{" "}
|
||||
<b>{selectedSite?.name || selectedSite?.id}</b>{" "}
|
||||
from the organization?
|
||||
{t('siteQuestionRemove', {selectedSite: selectedSite?.name || selectedSite?.id})}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{t('siteMessageRemove')}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Once removed, the site will no longer be
|
||||
accessible.{" "}
|
||||
<b>
|
||||
All resources and targets associated with
|
||||
the site will also be removed.
|
||||
</b>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the name of the site
|
||||
below.
|
||||
{t('siteMessageConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete Site"
|
||||
buttonText={t('siteConfirmDelete')}
|
||||
onConfirm={async () => deleteSite(selectedSite!.id)}
|
||||
string={selectedSite.name}
|
||||
title="Delete Site"
|
||||
title={t('siteDelete')}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type SiteInfoCardProps = {};
|
||||
|
||||
export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
const { site, updateSite } = useSiteContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const getConnectionTypeString = (type: string) => {
|
||||
if (type === "newt") {
|
||||
@@ -21,32 +23,32 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
} else if (type === "wireguard") {
|
||||
return "WireGuard";
|
||||
} else if (type === "local") {
|
||||
return "Local";
|
||||
return t('local');
|
||||
} else {
|
||||
return "Unknown";
|
||||
return t('unknown');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">Site Information</AlertTitle>
|
||||
<AlertTitle className="font-semibold">{t('siteInfo')}</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<InfoSections cols={2}>
|
||||
{(site.type == "newt" || site.type == "wireguard") && (
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Status</InfoSectionTitle>
|
||||
<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>Online</span>
|
||||
<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>Offline</span>
|
||||
<span>{t('offline')}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
@@ -54,7 +56,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
</>
|
||||
)}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Connection Type</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t('connectionType')}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{getConnectionTypeString(site.type)}
|
||||
</InfoSectionContent>
|
||||
|
||||
@@ -32,8 +32,8 @@ import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useState } from "react";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, ExternalLink } from "lucide-react";
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string().nonempty("Name is required"),
|
||||
@@ -50,6 +50,7 @@ export default function GeneralPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const form = useForm<GeneralFormValues>({
|
||||
resolver: zodResolver(GeneralFormSchema),
|
||||
@@ -71,10 +72,10 @@ export default function GeneralPage() {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update site",
|
||||
title: t("siteErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while updating the site."
|
||||
t("siteErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -85,8 +86,8 @@ export default function GeneralPage() {
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Site updated",
|
||||
description: "The site has been updated."
|
||||
title: t("siteUpdated"),
|
||||
description: t("siteUpdatedDescription")
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
@@ -99,10 +100,10 @@ export default function GeneralPage() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
General Settings
|
||||
{t("generalSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the general settings for this site
|
||||
{t("siteGeneralDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
@@ -119,14 +120,13 @@ export default function GeneralPage() {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>{t("name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the display name of the
|
||||
site.
|
||||
{t("siteNameDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -140,7 +140,9 @@ export default function GeneralPage() {
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="docker-socket-enabled"
|
||||
label="Enable Docker Socket"
|
||||
label={t(
|
||||
"enableDockerSocket"
|
||||
)}
|
||||
defaultChecked={
|
||||
field.value
|
||||
}
|
||||
@@ -151,20 +153,21 @@ export default function GeneralPage() {
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
Enable Docker Socket
|
||||
discovery for populating
|
||||
container information.
|
||||
Socket path must be provided
|
||||
to Newt.{" "}
|
||||
<a
|
||||
{t(
|
||||
"enableDockerSocketDescription"
|
||||
)}{" "}
|
||||
<Link
|
||||
href="https://docs.fossorial.io/Newt/overview#docker-socket-integration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center"
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
</a>
|
||||
<span>
|
||||
{t(
|
||||
"enableDockerSocketLink"
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -182,7 +185,7 @@ export default function GeneralPage() {
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Save General Settings
|
||||
{t("saveGeneralSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
BreadcrumbSeparator
|
||||
} from "@app/components/ui/breadcrumb";
|
||||
import SiteInfoCard from "./SiteInfoCard";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
interface SettingsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -38,9 +39,11 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
redirect(`/${params.orgId}/settings/sites`);
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: "General",
|
||||
title: t('general'),
|
||||
href: "/{orgId}/settings/sites/{niceId}/general"
|
||||
}
|
||||
];
|
||||
@@ -48,8 +51,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={`${site?.name} Settings`}
|
||||
description="Configure the settings on your site"
|
||||
title={t('siteSetting', {siteName: site?.name})}
|
||||
description={t('siteSettingDescription')}
|
||||
/>
|
||||
|
||||
<SiteProvider site={site}>
|
||||
|
||||
@@ -65,32 +65,7 @@ import {
|
||||
import Link from "next/link";
|
||||
import { QRCodeCanvas } from "qrcode.react";
|
||||
|
||||
const createSiteFormSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, { message: "Name must be at least 2 characters." })
|
||||
.max(30, {
|
||||
message: "Name must not be longer than 30 characters."
|
||||
}),
|
||||
method: z.enum(["newt", "wireguard", "local"]),
|
||||
copied: z.boolean(),
|
||||
clientAddress: z.string().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.method !== "local") {
|
||||
return data.copied;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Please confirm that you have copied the config.",
|
||||
path: ["copied"]
|
||||
}
|
||||
);
|
||||
|
||||
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type SiteType = "newt" | "wireguard" | "local";
|
||||
|
||||
@@ -125,28 +100,54 @@ export default function Page() {
|
||||
const api = createApiClient({ env });
|
||||
const { orgId } = useParams();
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const createSiteFormSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, { message: t('nameMin', {len: 2}) })
|
||||
.max(30, {
|
||||
message: t('nameMax', {len: 30})
|
||||
}),
|
||||
method: z.enum(["newt", "wireguard", "local"]),
|
||||
copied: z.boolean(),
|
||||
clientAddress: z.string().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.method !== "local") {
|
||||
return data.copied;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t('sitesConfirmCopy'),
|
||||
path: ["copied"]
|
||||
}
|
||||
);
|
||||
|
||||
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
|
||||
|
||||
const [tunnelTypes, setTunnelTypes] = useState<
|
||||
ReadonlyArray<TunnelTypeOption>
|
||||
>([
|
||||
{
|
||||
id: "newt",
|
||||
title: "Newt Tunnel (Recommended)",
|
||||
description:
|
||||
"Easiest way to create an entrypoint into your network. No extra setup.",
|
||||
title: t('siteNewtTunnel'),
|
||||
description: t('siteNewtTunnelDescription'),
|
||||
disabled: true
|
||||
},
|
||||
{
|
||||
id: "wireguard",
|
||||
title: "Basic WireGuard",
|
||||
description:
|
||||
"Use any WireGuard client to establish a tunnel. Manual NAT setup required.",
|
||||
title: t('siteWg'),
|
||||
description: t('siteWgDescription'),
|
||||
disabled: true
|
||||
},
|
||||
{
|
||||
id: "local",
|
||||
title: "Local",
|
||||
description: "Local resources only. No tunneling."
|
||||
title: t('local'),
|
||||
description: t('siteLocalDescription')
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -325,7 +326,7 @@ WantedBy=default.target`
|
||||
};
|
||||
|
||||
const getCommand = () => {
|
||||
const placeholder = ["Unknown command"];
|
||||
const placeholder = [t('unknownCommand')];
|
||||
if (!commands) {
|
||||
return placeholder;
|
||||
}
|
||||
@@ -387,8 +388,8 @@ WantedBy=default.target`
|
||||
if (!siteDefaults || !wgConfig) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating site",
|
||||
description: "Key pair or site defaults not found"
|
||||
title: t('siteErrorCreate'),
|
||||
description: t('siteErrorCreateKeyPair')
|
||||
});
|
||||
setCreateLoading(false);
|
||||
return;
|
||||
@@ -405,8 +406,8 @@ WantedBy=default.target`
|
||||
if (!siteDefaults) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating site",
|
||||
description: "Site defaults not found"
|
||||
title: t('siteErrorCreate'),
|
||||
description: t('siteErrorCreateDefaults')
|
||||
});
|
||||
setCreateLoading(false);
|
||||
return;
|
||||
@@ -429,7 +430,7 @@ WantedBy=default.target`
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating site",
|
||||
title: t('siteErrorCreate'),
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
});
|
||||
@@ -455,14 +456,14 @@ WantedBy=default.target`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch release info: ${response.statusText}`
|
||||
t('newtErrorFetchReleases', {err: response.statusText})
|
||||
);
|
||||
}
|
||||
const data = await response.json();
|
||||
const latestVersion = data.tag_name;
|
||||
newtVersion = latestVersion;
|
||||
} catch (error) {
|
||||
console.error("Error fetching latest release:", error);
|
||||
console.error(t('newtErrorFetchLatest', {err: error instanceof Error ? error.message : String(error)}));
|
||||
}
|
||||
|
||||
const generatedKeypair = generateKeypair();
|
||||
@@ -529,8 +530,8 @@ WantedBy=default.target`
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title="Create Site"
|
||||
description="Follow the steps below to create and connect a new site"
|
||||
title={t('siteCreate')}
|
||||
description={t('siteCreateDescription2')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -538,7 +539,7 @@ WantedBy=default.target`
|
||||
router.push(`/${orgId}/settings/sites`);
|
||||
}}
|
||||
>
|
||||
See All Sites
|
||||
{t('siteSeeAll')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -548,7 +549,7 @@ WantedBy=default.target`
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Site Information
|
||||
{t('siteInfo')}
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -564,7 +565,7 @@ WantedBy=default.target`
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name
|
||||
{t('name')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -574,8 +575,7 @@ WantedBy=default.target`
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the display
|
||||
name for the site.
|
||||
{t('siteNameDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -625,11 +625,10 @@ WantedBy=default.target`
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Tunnel Type
|
||||
{t('tunnelType')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Determine how you want to connect to your
|
||||
site
|
||||
{t('siteTunnelDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -649,18 +648,17 @@ WantedBy=default.target`
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Newt Credentials
|
||||
{t('siteNewtCredentials')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
This is how Newt will authenticate
|
||||
with the server
|
||||
{t('siteNewtCredentialsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<InfoSections cols={3}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Newt Endpoint
|
||||
{t('newtEndpoint')}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
@@ -672,7 +670,7 @@ WantedBy=default.target`
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Newt ID
|
||||
{t('newtId')}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
@@ -682,7 +680,7 @@ WantedBy=default.target`
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Newt Secret Key
|
||||
{t('newtSecretKey')}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
@@ -695,12 +693,10 @@ WantedBy=default.target`
|
||||
<Alert variant="neutral" className="">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Save Your Credentials
|
||||
{t('siteCredentialsSave')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
You will only be able to see
|
||||
this once. Make sure to copy it
|
||||
to a secure place.
|
||||
{t('siteCredentialsSaveDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -735,9 +731,7 @@ WantedBy=default.target`
|
||||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
I have
|
||||
copied the
|
||||
config
|
||||
{t('siteConfirmCopy')}
|
||||
</label>
|
||||
</div>
|
||||
<FormMessage />
|
||||
@@ -751,16 +745,16 @@ WantedBy=default.target`
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Install Newt
|
||||
{t('siteInstallNewt')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Get Newt running on your system
|
||||
{t('siteInstallNewtDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div>
|
||||
<p className="font-bold mb-3">
|
||||
Operating System
|
||||
{t('operatingSystem')}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{platforms.map((os) => (
|
||||
@@ -788,8 +782,8 @@ WantedBy=default.target`
|
||||
{["docker", "podman"].includes(
|
||||
platform
|
||||
)
|
||||
? "Method"
|
||||
: "Architecture"}
|
||||
? t('method')
|
||||
: t('architecture')}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{getArchitectures().map(
|
||||
@@ -816,7 +810,7 @@ WantedBy=default.target`
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<p className="font-bold mb-3">
|
||||
Commands
|
||||
{t('commands')}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<CopyTextBox
|
||||
@@ -837,11 +831,10 @@ WantedBy=default.target`
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
WireGuard Configuration
|
||||
{t('WgConfiguration')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Use the following configuration to
|
||||
connect to your network
|
||||
{t('WgConfigurationDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -862,12 +855,10 @@ WantedBy=default.target`
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Save Your Credentials
|
||||
{t('siteCredentialsSave')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
You will only be able to see this
|
||||
once. Make sure to copy it to a
|
||||
secure place.
|
||||
{t('siteCredentialsSaveDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -902,8 +893,7 @@ WantedBy=default.target`
|
||||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
I have copied
|
||||
the config
|
||||
{t('siteConfirmCopy')}
|
||||
</label>
|
||||
</div>
|
||||
<FormMessage />
|
||||
@@ -925,7 +915,7 @@ WantedBy=default.target`
|
||||
router.push(`/${orgId}/settings/sites`);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -935,7 +925,7 @@ WantedBy=default.target`
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
>
|
||||
Create Site
|
||||
{t('siteCreate')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AxiosResponse } from "axios";
|
||||
import SitesTable, { SiteRow } from "./SitesTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import SitesSplashCard from "./SitesSplashCard";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
type SitesPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
@@ -23,16 +24,18 @@ export default async function SitesPage(props: SitesPageProps) {
|
||||
sites = res.data.data.sites;
|
||||
} catch (e) {}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
function formatSize(mb: number, type: string): string {
|
||||
if (type === "local") {
|
||||
return "-"; // because we are not able to track the data use in a local site right now
|
||||
}
|
||||
if (mb >= 1024 * 1024) {
|
||||
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
|
||||
return t('terabytes', {count: (mb / (1024 * 1024)).toFixed(2)});
|
||||
} else if (mb >= 1024) {
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
return t('gigabytes', {count: (mb / 1024).toFixed(2)});
|
||||
} else {
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
return t('megabytes', {count: mb.toFixed(2)});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,8 +58,8 @@ export default async function SitesPage(props: SitesPageProps) {
|
||||
{/* <SitesSplashCard /> */}
|
||||
|
||||
<SettingsSectionTitle
|
||||
title="Manage Sites"
|
||||
description="Allow connectivity to your network through secure tunnels"
|
||||
title={t('siteManageSites')}
|
||||
description={t('siteDescription')}
|
||||
/>
|
||||
|
||||
<SitesTable sites={siteRows} orgId={params.orgId} />
|
||||
|
||||
@@ -27,6 +27,7 @@ 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>[];
|
||||
@@ -39,15 +40,18 @@ export function ApiKeysDataTable<TData, TValue>({
|
||||
columns,
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="API Keys"
|
||||
searchPlaceholder="Search API keys..."
|
||||
title={t('apiKeys')}
|
||||
searchPlaceholder={t('searchApiKeys')}
|
||||
searchColumn="name"
|
||||
onAdd={addApiKey}
|
||||
addButtonText="Generate API Key"
|
||||
addButtonText={t('apiKeysAdd')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import moment from "moment";
|
||||
import { ApiKeysDataTable } from "./ApiKeysDataTable";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type ApiKeyRow = {
|
||||
id: string;
|
||||
@@ -40,14 +41,16 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const deleteSite = (apiKeyId: string) => {
|
||||
api.delete(`/api-key/${apiKeyId}`)
|
||||
.catch((e) => {
|
||||
console.error("Error deleting API key", e);
|
||||
console.error(t('apiKeysErrorDelete'), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error deleting API key",
|
||||
description: formatAxiosError(e, "Error deleting API key")
|
||||
title: t('apiKeysErrorDelete'),
|
||||
description: formatAxiosError(e, t('apiKeysErrorDeleteMessage'))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
@@ -71,7 +74,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<span className="sr-only">{t('openMenu')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -81,7 +84,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
||||
setSelected(apiKeyROw);
|
||||
}}
|
||||
>
|
||||
<span>View settings</span>
|
||||
<span>{t('viewSettings')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -89,7 +92,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
<span className="text-red-500">{t('delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -106,7 +109,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
{t('name')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -114,7 +117,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
||||
},
|
||||
{
|
||||
accessorKey: "key",
|
||||
header: "Key",
|
||||
header: t('key'),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return <span className="font-mono">{r.key}</span>;
|
||||
@@ -122,7 +125,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created At",
|
||||
header: t('createdAt'),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return <span>{moment(r.createdAt).format("lll")} </span>;
|
||||
@@ -136,7 +139,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
||||
<div className="flex items-center justify-end">
|
||||
<Link href={`/admin/api-keys/${r.id}`}>
|
||||
<Button variant={"outlinePrimary"} className="ml-2">
|
||||
Edit
|
||||
{t('edit')}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -158,27 +161,24 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to remove the API key{" "}
|
||||
<b>{selected?.name || selected?.id}</b>?
|
||||
{t('apiKeysQuestionRemove', {selectedApiKey: selected?.name || selected?.id})}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>
|
||||
Once removed, the API key will no longer be
|
||||
able to be used.
|
||||
{t('apiKeysMessageRemove')}
|
||||
</b>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the name of the API key
|
||||
below.
|
||||
{t('apiKeysMessageConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete API Key"
|
||||
buttonText={t('apiKeysDeleteConfirm')}
|
||||
onConfirm={async () => deleteSite(selected!.id)}
|
||||
string={selected.name}
|
||||
title="Delete API Key"
|
||||
title={t('apiKeysDelete')}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { GetApiKeyResponse } from "@server/routers/apiKeys";
|
||||
import ApiKeyProvider from "@app/providers/ApiKeyProvider";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
interface SettingsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -24,6 +25,8 @@ interface SettingsLayoutProps {
|
||||
export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
const params = await props.params;
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
const { children } = props;
|
||||
|
||||
let apiKey = null;
|
||||
@@ -40,14 +43,14 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: "Permissions",
|
||||
title: t('apiKeysPermissionsTitle'),
|
||||
href: "/admin/api-keys/{apiKeyId}/permissions"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle title={`${apiKey?.name} Settings`} />
|
||||
<SettingsSectionTitle title={t('apiKeysSettings', {apiKeyName: apiKey?.name})} />
|
||||
|
||||
<ApiKeyProvider apiKey={apiKey}>
|
||||
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
||||
|
||||
@@ -18,12 +18,15 @@ import { ListApiKeyActionsResponse } from "@server/routers/apiKeys";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Page() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const { apiKeyId } = useParams();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const [loadingPage, setLoadingPage] = useState<boolean>(true);
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<
|
||||
Record<string, boolean>
|
||||
@@ -42,10 +45,10 @@ export default function Page() {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error loading API key actions",
|
||||
title: t('apiKeysPermissionsErrorLoadingActions'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"Error loading API key actions"
|
||||
t('apiKeysPermissionsErrorLoadingActions')
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -76,18 +79,18 @@ export default function Page() {
|
||||
)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Error setting permissions", e);
|
||||
console.error(t('apiKeysErrorSetPermission'), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error setting permissions",
|
||||
title: t('apiKeysErrorSetPermission'),
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
});
|
||||
|
||||
if (actionsRes && actionsRes.status === 200) {
|
||||
toast({
|
||||
title: "Permissions updated",
|
||||
description: "The permissions have been updated."
|
||||
title: t('apiKeysPermissionsUpdated'),
|
||||
description: t('apiKeysPermissionsUpdatedDescription')
|
||||
});
|
||||
}
|
||||
|
||||
@@ -101,10 +104,10 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Permissions
|
||||
{t('apiKeysPermissionsTitle')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Determine what this API key can do
|
||||
{t('apiKeysPermissionsGeneralSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -122,7 +125,7 @@ export default function Page() {
|
||||
loading={loadingSavePermissions}
|
||||
disabled={loadingSavePermissions}
|
||||
>
|
||||
Save Permissions
|
||||
{t('apiKeysPermissionsSave')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSectionBody>
|
||||
|
||||
@@ -54,40 +54,13 @@ import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import moment from "moment";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
|
||||
|
||||
const createFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: "Name must be at least 2 characters."
|
||||
})
|
||||
.max(255, {
|
||||
message: "Name must not be longer than 255 characters."
|
||||
})
|
||||
});
|
||||
|
||||
type CreateFormValues = z.infer<typeof createFormSchema>;
|
||||
|
||||
const copiedFormSchema = z
|
||||
.object({
|
||||
copied: z.boolean()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
return data.copied;
|
||||
},
|
||||
{
|
||||
message: "You must confirm that you have copied the API key.",
|
||||
path: ["copied"]
|
||||
}
|
||||
);
|
||||
|
||||
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Page() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [loadingPage, setLoadingPage] = useState(true);
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
@@ -96,6 +69,35 @@ export default function Page() {
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const createFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: t('nameMin', {len: 2})
|
||||
})
|
||||
.max(255, {
|
||||
message: t('nameMax', {len: 255})
|
||||
})
|
||||
});
|
||||
|
||||
type CreateFormValues = z.infer<typeof createFormSchema>;
|
||||
|
||||
const copiedFormSchema = z
|
||||
.object({
|
||||
copied: z.boolean()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
return data.copied;
|
||||
},
|
||||
{
|
||||
message: t('apiKeysConfirmCopy2'),
|
||||
path: ["copied"]
|
||||
}
|
||||
);
|
||||
|
||||
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
|
||||
|
||||
const form = useForm<CreateFormValues>({
|
||||
resolver: zodResolver(createFormSchema),
|
||||
defaultValues: {
|
||||
@@ -122,7 +124,7 @@ export default function Page() {
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating API key",
|
||||
title: t('apiKeysErrorCreate'),
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
});
|
||||
@@ -143,10 +145,10 @@ export default function Page() {
|
||||
)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Error setting permissions", e);
|
||||
console.error(t('apiKeysErrorSetPermission'), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error setting permissions",
|
||||
title: t('apiKeysErrorSetPermission'),
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
});
|
||||
@@ -179,8 +181,8 @@ export default function Page() {
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title="Generate API Key"
|
||||
description="Generate a new root access API key"
|
||||
title={t('apiKeysCreate')}
|
||||
description={t('apiKeysCreateDescription')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -188,7 +190,7 @@ export default function Page() {
|
||||
router.push(`/admin/api-keys`);
|
||||
}}
|
||||
>
|
||||
See All API Keys
|
||||
{t('apiKeysSeeAll')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -200,7 +202,7 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
API Key Information
|
||||
{t('apiKeysTitle')}
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -216,7 +218,7 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name
|
||||
{t('name')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -237,10 +239,10 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Permissions
|
||||
{t('apiKeysGeneralSettings')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Determine what this API key can do
|
||||
{t('apiKeysGeneralSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -260,14 +262,14 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Your API Key
|
||||
{t('apiKeysList')}
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<InfoSections cols={2}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Name
|
||||
{t('name')}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
@@ -277,7 +279,7 @@ export default function Page() {
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Created
|
||||
{t('created')}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{moment(
|
||||
@@ -290,17 +292,15 @@ export default function Page() {
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Save Your API Key
|
||||
{t('apiKeysSave')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
You will only be able to see this
|
||||
once. Make sure to copy it to a
|
||||
secure place.
|
||||
{t('apiKeysSaveDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<h4 className="font-semibold">
|
||||
Your API key is:
|
||||
{t('apiKeysInfo')}
|
||||
</h4>
|
||||
|
||||
<CopyTextBox
|
||||
@@ -338,8 +338,7 @@ export default function Page() {
|
||||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
I have copied
|
||||
the API key
|
||||
{t('apiKeysConfirmCopy')}
|
||||
</label>
|
||||
</div>
|
||||
<FormMessage />
|
||||
@@ -363,7 +362,7 @@ export default function Page() {
|
||||
router.push(`/admin/api-keys`);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{!apiKey && (
|
||||
@@ -375,7 +374,7 @@ export default function Page() {
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
>
|
||||
Generate
|
||||
{t('generate')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -386,7 +385,7 @@ export default function Page() {
|
||||
copiedForm.handleSubmit(onCopiedSubmit)();
|
||||
}}
|
||||
>
|
||||
Done
|
||||
{t('done')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AxiosResponse } from "axios";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { ListRootApiKeysResponse } from "@server/routers/apiKeys";
|
||||
import ApiKeysTable, { ApiKeyRow } from "./ApiKeysTable";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
type ApiKeyPageProps = {};
|
||||
|
||||
@@ -28,11 +29,13 @@ export default async function ApiKeysPage(props: ApiKeyPageProps) {
|
||||
};
|
||||
});
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage API Keys"
|
||||
description="API keys are used to authenticate with the integration API"
|
||||
title={t('apiKeysManage')}
|
||||
description={t('apiKeysDescription')}
|
||||
/>
|
||||
|
||||
<ApiKeysTable apiKeys={rows} />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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>[];
|
||||
@@ -14,15 +15,16 @@ export function IdpDataTable<TData, TValue>({
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="Identity Providers"
|
||||
searchPlaceholder="Search identity providers..."
|
||||
title={t('idp')}
|
||||
searchPlaceholder={t('idpSearch')}
|
||||
searchColumn="name"
|
||||
addButtonText="Add Identity Provider"
|
||||
addButtonText={t('idpAdd')}
|
||||
onAdd={() => {
|
||||
router.push("/admin/idp/create");
|
||||
}}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type IdpRow = {
|
||||
idpId: number;
|
||||
@@ -36,19 +37,20 @@ export default function IdpTable({ idps }: Props) {
|
||||
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: "Success",
|
||||
description: "Identity provider deleted successfully"
|
||||
title: t('success'),
|
||||
description: t('idpDeletedDescription')
|
||||
});
|
||||
setIsDeleteModalOpen(false);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
@@ -74,7 +76,7 @@ export default function IdpTable({ idps }: Props) {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<span className="sr-only">{t('openMenu')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -84,7 +86,7 @@ export default function IdpTable({ idps }: Props) {
|
||||
href={`/admin/idp/${r.idpId}/general`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
View settings
|
||||
{t('viewSettings')}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
@@ -93,7 +95,7 @@ export default function IdpTable({ idps }: Props) {
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
<span className="text-red-500">{t('delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -126,7 +128,7 @@ export default function IdpTable({ idps }: Props) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
{t('name')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -142,7 +144,7 @@ export default function IdpTable({ idps }: Props) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Type
|
||||
{t('type')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -162,7 +164,7 @@ export default function IdpTable({ idps }: Props) {
|
||||
<div className="flex items-center justify-end">
|
||||
<Link href={`/admin/idp/${siteRow.idpId}/general`}>
|
||||
<Button variant={"outlinePrimary"} className="ml-2">
|
||||
Edit
|
||||
{t('edit')}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -184,27 +186,22 @@ export default function IdpTable({ idps }: Props) {
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to permanently delete the
|
||||
identity provider <b>{selectedIdp.name}</b>?
|
||||
{t('idpQuestionRemove', {name: selectedIdp.name})}
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
This will remove the identity provider and
|
||||
all associated configurations. Users who
|
||||
authenticate through this provider will no
|
||||
longer be able to log in.
|
||||
{t('idpMessageRemove')}
|
||||
</b>
|
||||
</p>
|
||||
<p>
|
||||
To confirm, please type the name of the identity
|
||||
provider below.
|
||||
{t('idpMessageConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete Identity Provider"
|
||||
buttonText={t('idpConfirmDelete')}
|
||||
onConfirm={async () => deleteIdp(selectedIdp.idpId)}
|
||||
string={selectedIdp.name}
|
||||
title="Delete Identity Provider"
|
||||
title={t('idpDelete')}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -43,23 +43,7 @@ import {
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
|
||||
clientId: z.string().min(1, { message: "Client ID is required." }),
|
||||
clientSecret: z.string().min(1, { message: "Client Secret is required." }),
|
||||
authUrl: z.string().url({ message: "Auth URL must be a valid URL." }),
|
||||
tokenUrl: z.string().url({ message: "Token URL must be a valid URL." }),
|
||||
identifierPath: z
|
||||
.string()
|
||||
.min(1, { message: "Identifier Path is required." }),
|
||||
emailPath: z.string().optional(),
|
||||
namePath: z.string().optional(),
|
||||
scopes: z.string().min(1, { message: "Scopes are required." }),
|
||||
autoProvision: z.boolean().default(false)
|
||||
});
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const { env } = useEnvContext();
|
||||
@@ -71,6 +55,24 @@ export default function GeneralPage() {
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
|
||||
const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
|
||||
const t = useTranslations();
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string().min(2, { message: t('nameMin', {len: 2}) }),
|
||||
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)
|
||||
});
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
|
||||
const form = useForm<GeneralFormValues>({
|
||||
resolver: zodResolver(GeneralFormSchema),
|
||||
@@ -109,7 +111,7 @@ export default function GeneralPage() {
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
@@ -143,14 +145,14 @@ export default function GeneralPage() {
|
||||
|
||||
if (res.status === 200) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Identity provider updated successfully"
|
||||
title: t('success'),
|
||||
description: t('idpUpdatedDescription')
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
@@ -169,18 +171,17 @@ export default function GeneralPage() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
General Information
|
||||
{t('idpTitle')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the basic information for your identity
|
||||
provider
|
||||
{t('idpSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<InfoSections cols={3}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Redirect URL
|
||||
{t('redirectUrl')}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard text={redirectUrl} />
|
||||
@@ -191,13 +192,10 @@ export default function GeneralPage() {
|
||||
<Alert variant="neutral" className="">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
About Redirect URL
|
||||
{t('redirectUrlAbout')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
This is the URL to which users will be
|
||||
redirected after authentication. You need to
|
||||
configure this URL in your identity provider
|
||||
settings.
|
||||
{t('redirectUrlAboutDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<SettingsSectionForm>
|
||||
@@ -212,13 +210,12 @@ export default function GeneralPage() {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>{t('name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A display name for this
|
||||
identity provider
|
||||
{t('idpDisplayName')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -228,7 +225,7 @@ export default function GeneralPage() {
|
||||
<div className="flex items-start mb-0">
|
||||
<SwitchInput
|
||||
id="auto-provision-toggle"
|
||||
label="Auto Provision Users"
|
||||
label={t('idpAutoProvisionUsers')}
|
||||
defaultChecked={form.getValues(
|
||||
"autoProvision"
|
||||
)}
|
||||
@@ -241,10 +238,7 @@ export default function GeneralPage() {
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
When enabled, users will be
|
||||
automatically created in the system upon
|
||||
first login with the ability to map
|
||||
users to roles and organizations.
|
||||
{t('idpAutoProvisionUsersDescription')}
|
||||
</span>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -256,11 +250,10 @@ export default function GeneralPage() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
OAuth2/OIDC Configuration
|
||||
{t('idpOidcConfigure')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the OAuth2/OIDC provider endpoints and
|
||||
credentials
|
||||
{t('idpOidcConfigureDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -277,15 +270,13 @@ export default function GeneralPage() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Client ID
|
||||
{t('idpClientId')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 client ID
|
||||
from your identity
|
||||
provider
|
||||
{t('idpClientIdDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -298,7 +289,7 @@ export default function GeneralPage() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Client Secret
|
||||
{t('idpClientSecret')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -307,9 +298,7 @@ export default function GeneralPage() {
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 client secret
|
||||
from your identity
|
||||
provider
|
||||
{t('idpClientSecretDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -322,14 +311,13 @@ export default function GeneralPage() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Authorization URL
|
||||
{t('idpAuthUrl')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 authorization
|
||||
endpoint URL
|
||||
{t('idpAuthUrlDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -342,14 +330,13 @@ export default function GeneralPage() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Token URL
|
||||
{t('idpTokenUrl')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 token
|
||||
endpoint URL
|
||||
{t('idpTokenUrlDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -364,11 +351,10 @@ export default function GeneralPage() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Token Configuration
|
||||
{t('idpToken')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure how to extract user information from
|
||||
the ID token
|
||||
{t('idpTokenDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -382,19 +368,17 @@ export default function GeneralPage() {
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
About JMESPath
|
||||
{t('idpJmespathAbout')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
The paths below use JMESPath
|
||||
syntax to extract values from
|
||||
the ID token.
|
||||
{t('idpJmespathAboutDescription')}
|
||||
<a
|
||||
href="https://jmespath.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center"
|
||||
>
|
||||
Learn more about JMESPath{" "}
|
||||
{t('idpJmespathAboutDescriptionLink')}{" "}
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
</a>
|
||||
</AlertDescription>
|
||||
@@ -406,15 +390,13 @@ export default function GeneralPage() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Identifier Path
|
||||
{t('idpJmespathLabel')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The JMESPath to the user
|
||||
identifier in the ID
|
||||
token
|
||||
{t('idpJmespathLabelDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -427,15 +409,13 @@ export default function GeneralPage() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Email Path (Optional)
|
||||
{t('idpJmespathEmailPathOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The JMESPath to the
|
||||
user's email in the ID
|
||||
token
|
||||
{t('idpJmespathEmailPathOptionalDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -448,15 +428,13 @@ export default function GeneralPage() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name Path (Optional)
|
||||
{t('idpJmespathNamePathOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The JMESPath to the
|
||||
user's name in the ID
|
||||
token
|
||||
{t('idpJmespathNamePathOptionalDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -469,14 +447,13 @@ export default function GeneralPage() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Scopes
|
||||
{t('idpOidcConfigureScopes')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Space-separated list of
|
||||
OAuth2 scopes to request
|
||||
{t('idpOidcConfigureScopesDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -497,7 +474,7 @@ export default function GeneralPage() {
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Save General Settings
|
||||
{t('saveGeneralSettings')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from "@app/components/ui/breadcrumb";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
interface SettingsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -24,6 +25,7 @@ interface SettingsLayoutProps {
|
||||
export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
const params = await props.params;
|
||||
const { children } = props;
|
||||
const t = await getTranslations();
|
||||
|
||||
let idp = null;
|
||||
try {
|
||||
@@ -38,11 +40,11 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
|
||||
const navItems: HorizontalTabs = [
|
||||
{
|
||||
title: "General",
|
||||
title: t('general'),
|
||||
href: `/admin/idp/${params.idpId}/general`
|
||||
},
|
||||
{
|
||||
title: "Organization Policies",
|
||||
title: t('orgPolicies'),
|
||||
href: `/admin/idp/${params.idpId}/policies`
|
||||
}
|
||||
];
|
||||
@@ -50,8 +52,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={`${idp.idp.name} Settings`}
|
||||
description="Configure the settings for your identity provider"
|
||||
title={t('idpSettings', { idpName: idp.idp.name })}
|
||||
description={t('idpSettingsDescription')}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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>[];
|
||||
@@ -14,14 +15,17 @@ export function PolicyDataTable<TData, TValue>({
|
||||
data,
|
||||
onAdd
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="Organization Policies"
|
||||
searchPlaceholder="Search organization policies..."
|
||||
title={t('orgPolicies')}
|
||||
searchPlaceholder={t('orgPoliciesSearch')}
|
||||
searchColumn="orgId"
|
||||
addButtonText="Add Organization Policy"
|
||||
addButtonText={t('orgPoliciesAdd')}
|
||||
onAdd={onAdd}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} 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;
|
||||
@@ -34,6 +35,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props) {
|
||||
const t = useTranslations();
|
||||
const columns: ColumnDef<PolicyRow>[] = [
|
||||
{
|
||||
id: "dots",
|
||||
@@ -44,7 +46,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<span className="sr-only">{t('openMenu')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -54,7 +56,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
||||
onDelete(r.orgId);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
<span className="text-red-500">{t('delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -71,7 +73,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Organization ID
|
||||
{t('orgId')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -87,7 +89,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Role Mapping
|
||||
{t('roleMapping')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -114,7 +116,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Organization Mapping
|
||||
{t('orgMapping')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -142,7 +144,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
||||
className="ml-2"
|
||||
onClick={() => onEdit(policy)}
|
||||
>
|
||||
Edit
|
||||
{t('edit')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -63,31 +63,19 @@ import {
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm
|
||||
} from "@app/components/Settings";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type Organization = {
|
||||
orgId: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const policyFormSchema = z.object({
|
||||
orgId: z.string().min(1, { message: "Organization is required" }),
|
||||
roleMapping: z.string().optional(),
|
||||
orgMapping: z.string().optional()
|
||||
});
|
||||
|
||||
const defaultMappingsSchema = z.object({
|
||||
defaultRoleMapping: z.string().optional(),
|
||||
defaultOrgMapping: z.string().optional()
|
||||
});
|
||||
|
||||
type PolicyFormValues = z.infer<typeof policyFormSchema>;
|
||||
type DefaultMappingsValues = z.infer<typeof defaultMappingsSchema>;
|
||||
|
||||
export default function PoliciesPage() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const { idpId } = useParams();
|
||||
const t = useTranslations();
|
||||
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [addPolicyLoading, setAddPolicyLoading] = useState(false);
|
||||
@@ -100,6 +88,20 @@ export default function PoliciesPage() {
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [editingPolicy, setEditingPolicy] = useState<PolicyRow | null>(null);
|
||||
|
||||
const policyFormSchema = z.object({
|
||||
orgId: z.string().min(1, { message: t('orgRequired') }),
|
||||
roleMapping: z.string().optional(),
|
||||
orgMapping: z.string().optional()
|
||||
});
|
||||
|
||||
const defaultMappingsSchema = z.object({
|
||||
defaultRoleMapping: z.string().optional(),
|
||||
defaultOrgMapping: z.string().optional()
|
||||
});
|
||||
|
||||
type PolicyFormValues = z.infer<typeof policyFormSchema>;
|
||||
type DefaultMappingsValues = z.infer<typeof defaultMappingsSchema>;
|
||||
|
||||
const form = useForm<PolicyFormValues>({
|
||||
resolver: zodResolver(policyFormSchema),
|
||||
defaultValues: {
|
||||
@@ -131,7 +133,7 @@ export default function PoliciesPage() {
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
@@ -146,7 +148,7 @@ export default function PoliciesPage() {
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
@@ -165,7 +167,7 @@ export default function PoliciesPage() {
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
@@ -200,15 +202,15 @@ export default function PoliciesPage() {
|
||||
};
|
||||
setPolicies([...policies, newPolicy]);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Policy added successfully"
|
||||
title: t('success'),
|
||||
description: t('orgPolicyAddedDescription')
|
||||
});
|
||||
setShowAddDialog(false);
|
||||
form.reset();
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
@@ -242,8 +244,8 @@ export default function PoliciesPage() {
|
||||
)
|
||||
);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Policy updated successfully"
|
||||
title: t('success'),
|
||||
description: t('orgPolicyUpdatedDescription')
|
||||
});
|
||||
setShowAddDialog(false);
|
||||
setEditingPolicy(null);
|
||||
@@ -251,7 +253,7 @@ export default function PoliciesPage() {
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
@@ -269,13 +271,13 @@ export default function PoliciesPage() {
|
||||
policies.filter((policy) => policy.orgId !== orgId)
|
||||
);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Policy deleted successfully"
|
||||
title: t('success'),
|
||||
description: t('orgPolicyDeletedDescription')
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
@@ -293,13 +295,13 @@ export default function PoliciesPage() {
|
||||
});
|
||||
if (res.status === 200) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Default mappings updated successfully"
|
||||
title: t('success'),
|
||||
description: t('defaultMappingsUpdatedDescription')
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
@@ -318,21 +320,18 @@ export default function PoliciesPage() {
|
||||
<Alert variant="neutral" className="mb-6">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
About Organization Policies
|
||||
{t('orgPoliciesAbout')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Organization policies are used to control access to
|
||||
organizations based on the user's ID token. You can
|
||||
specify JMESPath expressions to extract role and
|
||||
organization information from the ID token. For more
|
||||
information, see{" "}
|
||||
{/*TODO(vlalx): Validate replacing */}
|
||||
{t('orgPoliciesAboutDescription')}{" "}
|
||||
<Link
|
||||
href="https://docs.fossorial.io/Pangolin/Identity%20Providers/auto-provision"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
the documentation
|
||||
{t('orgPoliciesAboutDescriptionLink')}
|
||||
<ExternalLink className="ml-1 h-4 w-4 inline" />
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
@@ -341,13 +340,10 @@ export default function PoliciesPage() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Default Mappings (Optional)
|
||||
{t('defaultMappingsOptional')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
The default mappings are used when when there is not
|
||||
an organization policy defined for an organization.
|
||||
You can specify the default role and organization
|
||||
mappings to fall back to here.
|
||||
{t('defaultMappingsOptionalDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -366,16 +362,13 @@ export default function PoliciesPage() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Default Role Mapping
|
||||
{t('defaultMappingsRole')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The result of this
|
||||
expression must return the
|
||||
role name as defined in the
|
||||
organization as a string.
|
||||
{t('defaultMappingsRoleDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -388,16 +381,13 @@ export default function PoliciesPage() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Default Organization Mapping
|
||||
{t('defaultMappingsOrg')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This expression must return
|
||||
the org ID or true for the
|
||||
user to be allowed to access
|
||||
the organization.
|
||||
{t('defaultMappingsOrgDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -412,7 +402,7 @@ export default function PoliciesPage() {
|
||||
form="policy-default-mappings-form"
|
||||
loading={updateDefaultMappingsLoading}
|
||||
>
|
||||
Save Default Mappings
|
||||
{t('defaultMappingsSubmit')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSectionBody>
|
||||
@@ -455,11 +445,11 @@ export default function PoliciesPage() {
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{editingPolicy
|
||||
? "Edit Organization Policy"
|
||||
: "Add Organization Policy"}
|
||||
? t('orgPoliciesEdit')
|
||||
: t('orgPoliciesAdd')}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Configure access for an organization
|
||||
{t('orgPolicyConfig')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
@@ -476,7 +466,7 @@ export default function PoliciesPage() {
|
||||
name="orgId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Organization</FormLabel>
|
||||
<FormLabel>{t('org')}</FormLabel>
|
||||
{editingPolicy ? (
|
||||
<Input {...field} disabled />
|
||||
) : (
|
||||
@@ -500,18 +490,17 @@ export default function PoliciesPage() {
|
||||
org.orgId ===
|
||||
field.value
|
||||
)?.name
|
||||
: "Select organization"}
|
||||
: t('orgSelect')}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search org" />
|
||||
<CommandInput placeholder={t('orgSearch')} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No org
|
||||
found.
|
||||
{t('orgNotFound')}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{organizations.map(
|
||||
@@ -562,16 +551,13 @@ export default function PoliciesPage() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Role Mapping Path (Optional)
|
||||
{t('roleMappingPathOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The result of this expression
|
||||
must return the role name as
|
||||
defined in the organization as a
|
||||
string.
|
||||
{t('defaultMappingsRoleDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -584,17 +570,13 @@ export default function PoliciesPage() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Organization Mapping Path
|
||||
(Optional)
|
||||
{t('orgMappingPathOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This expression must return the
|
||||
org ID or true for the user to
|
||||
be allowed to access the
|
||||
organization.
|
||||
{t('defaultMappingsOrgDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -605,7 +587,7 @@ export default function PoliciesPage() {
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -621,7 +603,7 @@ export default function PoliciesPage() {
|
||||
: addPolicyLoading
|
||||
}
|
||||
>
|
||||
{editingPolicy ? "Update Policy" : "Add Policy"}
|
||||
{editingPolicy ? t('orgPolicyUpdate') : t('orgPolicyAdd')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
||||
@@ -37,38 +37,7 @@ import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
|
||||
const createIdpFormSchema = z.object({
|
||||
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
|
||||
type: z.enum(["oidc"]),
|
||||
clientId: z.string().min(1, { message: "Client ID is required." }),
|
||||
clientSecret: z.string().min(1, { message: "Client Secret is required." }),
|
||||
authUrl: z.string().url({ message: "Auth URL must be a valid URL." }),
|
||||
tokenUrl: z.string().url({ message: "Token URL must be a valid URL." }),
|
||||
identifierPath: z
|
||||
.string()
|
||||
.min(1, { message: "Identifier Path is required." }),
|
||||
emailPath: z.string().optional(),
|
||||
namePath: z.string().optional(),
|
||||
scopes: z.string().min(1, { message: "Scopes are required." }),
|
||||
autoProvision: z.boolean().default(false)
|
||||
});
|
||||
|
||||
type CreateIdpFormValues = z.infer<typeof createIdpFormSchema>;
|
||||
|
||||
interface ProviderTypeOption {
|
||||
id: "oidc";
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const providerTypes: ReadonlyArray<ProviderTypeOption> = [
|
||||
{
|
||||
id: "oidc",
|
||||
title: "OAuth2/OIDC",
|
||||
description: "Configure an OpenID Connect identity provider"
|
||||
}
|
||||
];
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Page() {
|
||||
const { env } = useEnvContext();
|
||||
@@ -76,6 +45,39 @@ export default function Page() {
|
||||
const router = useRouter();
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
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)
|
||||
});
|
||||
|
||||
type CreateIdpFormValues = z.infer<typeof createIdpFormSchema>;
|
||||
|
||||
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),
|
||||
@@ -115,14 +117,14 @@ export default function Page() {
|
||||
|
||||
if (res.status === 201) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Identity provider created successfully"
|
||||
title: t('success'),
|
||||
description: t('idpCreatedDescription')
|
||||
});
|
||||
router.push(`/admin/idp/${res.data.data.idpId}`);
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
@@ -135,8 +137,8 @@ export default function Page() {
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title="Create Identity Provider"
|
||||
description="Configure a new identity provider for user authentication"
|
||||
title={t('idpCreate')}
|
||||
description={t('idpCreateDescription')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -144,7 +146,7 @@ export default function Page() {
|
||||
router.push("/admin/idp");
|
||||
}}
|
||||
>
|
||||
See All Identity Providers
|
||||
{t('idpSeeAll')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -152,11 +154,10 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
General Information
|
||||
{t('idpTitle')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the basic information for your identity
|
||||
provider
|
||||
{t('idpCreateSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -172,13 +173,12 @@ export default function Page() {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>{t('name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A display name for this
|
||||
identity provider
|
||||
{t('idpDisplayName')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -188,7 +188,7 @@ export default function Page() {
|
||||
<div className="flex items-start mb-0">
|
||||
<SwitchInput
|
||||
id="auto-provision-toggle"
|
||||
label="Auto Provision Users"
|
||||
label={t('idpAutoProvisionUsers')}
|
||||
defaultChecked={form.getValues(
|
||||
"autoProvision"
|
||||
)}
|
||||
@@ -201,10 +201,7 @@ export default function Page() {
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
When enabled, users will be
|
||||
automatically created in the system upon
|
||||
first login with the ability to map
|
||||
users to roles and organizations.
|
||||
{t('idpAutoProvisionUsersDescription')}
|
||||
</span>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -215,11 +212,10 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Provider Type
|
||||
{t('idpType')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Select the type of identity provider you want to
|
||||
configure
|
||||
{t('idpTypeDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -239,11 +235,10 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
OAuth2/OIDC Configuration
|
||||
{t('idpOidcConfigure')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the OAuth2/OIDC provider endpoints
|
||||
and credentials
|
||||
{t('idpOidcConfigureDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -259,15 +254,13 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Client ID
|
||||
{t('idpClientId')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 client ID
|
||||
from your identity
|
||||
provider
|
||||
{t('idpClientIdDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -280,7 +273,7 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Client Secret
|
||||
{t('idpClientSecret')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -289,9 +282,7 @@ export default function Page() {
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 client secret
|
||||
from your identity
|
||||
provider
|
||||
{t('idpClientSecretDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -304,7 +295,7 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Authorization URL
|
||||
{t('idpAuthUrl')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -313,8 +304,7 @@ export default function Page() {
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 authorization
|
||||
endpoint URL
|
||||
{t('idpAuthUrlDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -327,7 +317,7 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Token URL
|
||||
{t('idpTokenUrl')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -336,8 +326,7 @@ export default function Page() {
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 token
|
||||
endpoint URL
|
||||
{t('idpTokenUrlDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -349,14 +338,10 @@ export default function Page() {
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Important Information
|
||||
{t('idpOidcConfigureAlert')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
After creating the identity provider,
|
||||
you will need to configure the callback
|
||||
URL in your identity provider's
|
||||
settings. The callback URL will be
|
||||
provided after successful creation.
|
||||
{t('idpOidcConfigureAlertDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</SettingsSectionBody>
|
||||
@@ -365,11 +350,10 @@ export default function Page() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Token Configuration
|
||||
{t('idpToken')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure how to extract user information
|
||||
from the ID token
|
||||
{t('idpTokenDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -382,19 +366,17 @@ export default function Page() {
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
About JMESPath
|
||||
{t('idpJmespathAbout')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
The paths below use JMESPath
|
||||
syntax to extract values from
|
||||
the ID token.
|
||||
{t('idpJmespathAboutDescription')}{" "}
|
||||
<a
|
||||
href="https://jmespath.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center"
|
||||
>
|
||||
Learn more about JMESPath{" "}
|
||||
{t('idpJmespathAboutDescriptionLink')}{" "}
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
</a>
|
||||
</AlertDescription>
|
||||
@@ -406,15 +388,13 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Identifier Path
|
||||
{t('idpJmespathLabel')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The path to the user
|
||||
identifier in the ID
|
||||
token
|
||||
{t('idpJmespathLabelDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -427,15 +407,13 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Email Path (Optional)
|
||||
{t('idpJmespathEmailPathOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The path to the
|
||||
user's email in the ID
|
||||
token
|
||||
{t('idpJmespathEmailPathOptionalDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -448,15 +426,13 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name Path (Optional)
|
||||
{t('idpJmespathNamePathOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The path to the
|
||||
user's name in the ID
|
||||
token
|
||||
{t('idpJmespathNamePathOptionalDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -469,14 +445,13 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Scopes
|
||||
{t('idpOidcConfigureScopes')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Space-separated list of
|
||||
OAuth2 scopes to request
|
||||
{t('idpOidcConfigureScopesDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -498,7 +473,7 @@ export default function Page() {
|
||||
router.push("/admin/idp");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -506,7 +481,7 @@ export default function Page() {
|
||||
loading={createLoading}
|
||||
onClick={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
Create Identity Provider
|
||||
{t('idpSubmit')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import IdpTable, { IdpRow } from "./AdminIdpTable";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export default async function IdpPage() {
|
||||
let idps: IdpRow[] = [];
|
||||
@@ -15,12 +16,14 @@ export default async function IdpPage() {
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage Identity Providers"
|
||||
description="View and manage identity providers in the system"
|
||||
title={t('idpManage')}
|
||||
description={t('idpManageDescription')}
|
||||
/>
|
||||
<IdpTable idps={idps} />
|
||||
</>
|
||||
|
||||
@@ -8,6 +8,7 @@ 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[];
|
||||
@@ -27,6 +28,9 @@ export function LicenseKeysDataTable({
|
||||
onDelete,
|
||||
onCreate
|
||||
}: LicenseKeysDataTableProps) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const columns: ColumnDef<LicenseKeyCache>[] = [
|
||||
{
|
||||
accessorKey: "licenseKey",
|
||||
@@ -38,7 +42,7 @@ export function LicenseKeysDataTable({
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
License Key
|
||||
{t('licenseKey')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -63,13 +67,13 @@ export function LicenseKeysDataTable({
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Valid
|
||||
{t('valid')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return row.original.valid ? "Yes" : "No";
|
||||
return row.original.valid ? t('yes') : t('no');
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -82,7 +86,7 @@ export function LicenseKeysDataTable({
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Type
|
||||
{t('type')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -90,7 +94,7 @@ export function LicenseKeysDataTable({
|
||||
cell: ({ row }) => {
|
||||
const type = row.original.type;
|
||||
const label =
|
||||
type === "SITES" ? "Additional Sites" : "Host License";
|
||||
type === "SITES" ? t('sitesAdditional') : t('licenseHost');
|
||||
const variant = type === "SITES" ? "secondary" : "default";
|
||||
return row.original.valid ? (
|
||||
<Badge variant={variant}>{label}</Badge>
|
||||
@@ -107,7 +111,7 @@ export function LicenseKeysDataTable({
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Number of Sites
|
||||
{t('numberOfSites')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -121,7 +125,7 @@ export function LicenseKeysDataTable({
|
||||
variant="outlinePrimary"
|
||||
onClick={() => onDelete(row.original)}
|
||||
>
|
||||
Delete
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -132,11 +136,11 @@ export function LicenseKeysDataTable({
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={licenseKeys}
|
||||
title="License Keys"
|
||||
searchPlaceholder="Search license keys..."
|
||||
title={t('licenseKeys')}
|
||||
searchPlaceholder={t('licenseKeySearch')}
|
||||
searchColumn="licenseKey"
|
||||
onAdd={onCreate}
|
||||
addButtonText="Add License Key"
|
||||
addButtonText={t('licenseKeyAdd')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type SitePriceCalculatorProps = {
|
||||
isOpen: boolean;
|
||||
@@ -55,27 +56,26 @@ export function SitePriceCalculator({
|
||||
? licenseFlatRate + siteCount * pricePerSite
|
||||
: siteCount * pricePerSite;
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Credenza open={isOpen} onOpenChange={onOpenChange}>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{mode === "license"
|
||||
? "Purchase License"
|
||||
: "Purchase Additional Sites"}
|
||||
? t('licensePurchase')
|
||||
: t('licensePurchaseSites')}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Choose how many sites you want to{" "}
|
||||
{mode === "license"
|
||||
? "purchase a license for. You can always add more sites later."
|
||||
: "add to your existing license."}
|
||||
{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">
|
||||
Number of Sites
|
||||
{t('numberOfSites')}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
@@ -83,7 +83,7 @@ export function SitePriceCalculator({
|
||||
size="icon"
|
||||
onClick={decrementSites}
|
||||
disabled={siteCount <= 1}
|
||||
aria-label="Decrease site count"
|
||||
aria-label={t('sitestCountDecrease')}
|
||||
>
|
||||
<MinusCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
@@ -94,7 +94,7 @@ export function SitePriceCalculator({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={incrementSites}
|
||||
aria-label="Increase site count"
|
||||
aria-label={t('sitestCountIncrease')}
|
||||
>
|
||||
<PlusCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
@@ -103,15 +103,14 @@ export function SitePriceCalculator({
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-muted-foreground text-sm mt-2 text-center">
|
||||
For the most up-to-date pricing and discounts,
|
||||
please visit the{" "}
|
||||
{t('licensePricingPage')}
|
||||
<a
|
||||
href="https://docs.fossorial.io/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
pricing page
|
||||
{t('pricingPage')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
@@ -120,10 +119,10 @@ export function SitePriceCalculator({
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button variant="outline">{t('cancel')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button onClick={continueToPayment}>
|
||||
See Purchase Portal
|
||||
{t('pricingPortal')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
||||
@@ -54,16 +54,7 @@ import Link from "next/link";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
|
||||
const formSchema = z.object({
|
||||
licenseKey: z
|
||||
.string()
|
||||
.nonempty({ message: "License key is required" })
|
||||
.max(255),
|
||||
agreeToTerms: z.boolean().refine((val) => val === true, {
|
||||
message: "You must agree to the license terms"
|
||||
})
|
||||
});
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
function obfuscateLicenseKey(key: string): string {
|
||||
if (key.length <= 8) return key;
|
||||
@@ -94,6 +85,18 @@ export default function LicensePage() {
|
||||
const [isRecheckingLicense, setIsRecheckingLicense] = useState(false);
|
||||
const { supporterStatus } = useSupporterStatusContext();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const formSchema = z.object({
|
||||
licenseKey: z
|
||||
.string()
|
||||
.nonempty({ message: t('licenseKeyRequired') })
|
||||
.max(255),
|
||||
agreeToTerms: z.boolean().refine((val) => val === true, {
|
||||
message: t('licenseTermsAgree')
|
||||
})
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
@@ -127,10 +130,10 @@ export default function LicensePage() {
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Failed to load license keys",
|
||||
title: t('licenseErrorKeyLoad'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred loading license keys"
|
||||
t('licenseErrorKeyLoadDescription')
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -146,16 +149,16 @@ export default function LicensePage() {
|
||||
}
|
||||
await loadLicenseKeys();
|
||||
toast({
|
||||
title: "License key deleted",
|
||||
description: "The license key has been deleted"
|
||||
title: t('licenseKeyDeleted'),
|
||||
description: t('licenseKeyDeletedDescription')
|
||||
});
|
||||
setIsDeleteModalOpen(false);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Failed to delete license key",
|
||||
title: t('licenseErrorKeyDelete'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred deleting license key"
|
||||
t('licenseErrorKeyDeleteDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
@@ -172,15 +175,15 @@ export default function LicensePage() {
|
||||
}
|
||||
await loadLicenseKeys();
|
||||
toast({
|
||||
title: "License keys rechecked",
|
||||
description: "All license keys have been rechecked"
|
||||
title: t('licenseErrorKeyRechecked'),
|
||||
description: t('licenseErrorKeyRecheckedDescription')
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Failed to recheck license keys",
|
||||
title: t('licenseErrorKeyRecheck'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred rechecking license keys"
|
||||
t('licenseErrorKeyRecheckDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
@@ -199,8 +202,8 @@ export default function LicensePage() {
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "License key activated",
|
||||
description: "The license key has been successfully activated."
|
||||
title: t('licenseKeyActivated'),
|
||||
description: t('licenseKeyActivatedDescription')
|
||||
});
|
||||
|
||||
setIsCreateModalOpen(false);
|
||||
@@ -209,10 +212,10 @@ export default function LicensePage() {
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to activate license key",
|
||||
title: t('licenseErrorKeyActivate'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while activating the license key."
|
||||
t('licenseErrorKeyActivateDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
@@ -243,9 +246,9 @@ export default function LicensePage() {
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Activate License Key</CredenzaTitle>
|
||||
<CredenzaTitle>{t('licenseActivateKey')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Enter a license key to activate it.
|
||||
{t('licenseActivateKeyDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
@@ -260,7 +263,7 @@ export default function LicensePage() {
|
||||
name="licenseKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>License Key</FormLabel>
|
||||
<FormLabel>{t('licenseKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
@@ -283,12 +286,7 @@ export default function LicensePage() {
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
By checking this box, you
|
||||
confirm that you have read
|
||||
and agree to the license
|
||||
terms corresponding to the
|
||||
tier associated with your
|
||||
license key.
|
||||
{t('licenseAgreement')}
|
||||
{/* <br /> */}
|
||||
{/* <Link */}
|
||||
{/* href="https://fossorial.io/license.html" */}
|
||||
@@ -296,9 +294,7 @@ export default function LicensePage() {
|
||||
{/* rel="noopener noreferrer" */}
|
||||
{/* className="text-primary hover:underline" */}
|
||||
{/* > */}
|
||||
{/* View Fossorial */}
|
||||
{/* Commercial License & */}
|
||||
{/* Subscription Terms */}
|
||||
{/* {t('fossorialLicense')} */}
|
||||
{/* </Link> */}
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
@@ -311,7 +307,7 @@ export default function LicensePage() {
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -319,7 +315,7 @@ export default function LicensePage() {
|
||||
loading={isActivatingLicense}
|
||||
disabled={isActivatingLicense}
|
||||
>
|
||||
Activate License
|
||||
{t('licenseActivate')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
@@ -335,48 +331,39 @@ export default function LicensePage() {
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to delete the license key{" "}
|
||||
<b>
|
||||
{obfuscateLicenseKey(
|
||||
selectedLicenseKey.licenseKey
|
||||
)}
|
||||
</b>
|
||||
?
|
||||
{t('licenseQuestionRemove', {selectedKey: obfuscateLicenseKey(selectedLicenseKey.licenseKey)})}
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
This will remove the license key and all
|
||||
associated permissions granted by it.
|
||||
{t('licenseMessageRemove')}
|
||||
</b>
|
||||
</p>
|
||||
<p>
|
||||
To confirm, please type the license key below.
|
||||
{t('licenseMessageConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete License Key"
|
||||
buttonText={t('licenseKeyDeleteConfirm')}
|
||||
onConfirm={async () =>
|
||||
deleteLicenseKey(selectedLicenseKey.licenseKeyEncrypted)
|
||||
}
|
||||
string={selectedLicenseKey.licenseKey}
|
||||
title="Delete License Key"
|
||||
title={t('licenseKeyDelete')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsSectionTitle
|
||||
title="Manage License Status"
|
||||
description="View and manage license keys in the system"
|
||||
title={t('licenseTitle')}
|
||||
description={t('licenseTitleDescription')}
|
||||
/>
|
||||
|
||||
<Alert variant="neutral" className="mb-6">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
About Licensing
|
||||
{t('licenseAbout')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
This is for business and enterprise users who are using
|
||||
Pangolin in a commercial environment. If you are using
|
||||
Pangolin for personal use, you can ignore this section.
|
||||
{t('licenseAboutDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -384,9 +371,9 @@ export default function LicensePage() {
|
||||
<SettingsSectionGrid cols={2}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SSTitle>Host License</SSTitle>
|
||||
<SSTitle>{t('licenseHost')}</SSTitle>
|
||||
<SettingsSectionDescription>
|
||||
Manage the main license key for the host.
|
||||
{t('licenseHostDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<div className="space-y-4">
|
||||
@@ -397,23 +384,23 @@ export default function LicensePage() {
|
||||
<Check />
|
||||
{licenseStatus?.tier ===
|
||||
"PROFESSIONAL"
|
||||
? "Commercial License"
|
||||
? t('licenseTierCommercial')
|
||||
: licenseStatus?.tier ===
|
||||
"ENTERPRISE"
|
||||
? "Commercial License"
|
||||
: "Licensed"}
|
||||
? t('licenseTierCommercial')
|
||||
: t('licensed')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{supporterStatus?.visible ? (
|
||||
<div className="text-2xl">
|
||||
Community Edition
|
||||
{t('communityEdition')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-2xl flex items-center gap-2 text-pink-500">
|
||||
<Heart />
|
||||
Community Edition
|
||||
{t('communityEdition')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -422,7 +409,7 @@ export default function LicensePage() {
|
||||
{licenseStatus?.hostId && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
Host ID
|
||||
{t('hostId')}
|
||||
</div>
|
||||
<CopyTextBox text={licenseStatus.hostId} />
|
||||
</div>
|
||||
@@ -430,7 +417,7 @@ export default function LicensePage() {
|
||||
{hostLicense && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
License Key
|
||||
{t('licenseKey')}
|
||||
</div>
|
||||
<CopyTextBox
|
||||
text={hostLicense}
|
||||
@@ -448,39 +435,33 @@ export default function LicensePage() {
|
||||
disabled={isRecheckingLicense}
|
||||
loading={isRecheckingLicense}
|
||||
>
|
||||
Recheck All Keys
|
||||
{t('licenseReckeckAll')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SSTitle>Sites Usage</SSTitle>
|
||||
<SSTitle>{t('licenseSiteUsage')}</SSTitle>
|
||||
<SettingsSectionDescription>
|
||||
View the number of sites using this license.
|
||||
{t('licenseSiteUsageDecsription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-2xl">
|
||||
{licenseStatus?.usedSites || 0}{" "}
|
||||
{licenseStatus?.usedSites === 1
|
||||
? "site"
|
||||
: "sites"}{" "}
|
||||
in system
|
||||
{t('licenseSitesUsed', {count: licenseStatus?.usedSites || 0})}
|
||||
</div>
|
||||
</div>
|
||||
{!licenseStatus?.isHostLicensed && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
There is no limit on the number of sites
|
||||
using an unlicensed host.
|
||||
{t('licenseNoSiteLimit')}
|
||||
</p>
|
||||
)}
|
||||
{licenseStatus?.maxSites && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{licenseStatus.usedSites || 0} of{" "}
|
||||
{licenseStatus.maxSites} sites used
|
||||
{t('licenseSitesUsedMax', {usedSites: licenseStatus.usedSites || 0, maxSites: licenseStatus.maxSites})}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{Math.round(
|
||||
@@ -512,7 +493,7 @@ export default function LicensePage() {
|
||||
{/* setIsPurchaseModalOpen(true); */}
|
||||
{/* }} */}
|
||||
{/* > */}
|
||||
{/* Purchase License */}
|
||||
{/* {t('licensePurchase')} */}
|
||||
{/* </Button> */}
|
||||
{/* </> */}
|
||||
{/* ) : ( */}
|
||||
@@ -524,7 +505,7 @@ export default function LicensePage() {
|
||||
{/* setIsPurchaseModalOpen(true); */}
|
||||
{/* }} */}
|
||||
{/* > */}
|
||||
{/* Purchase Additional Sites */}
|
||||
{/* {t('licensePurchaseSites')} */}
|
||||
{/* </Button> */}
|
||||
{/* </> */}
|
||||
{/* )} */}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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>[];
|
||||
@@ -14,12 +15,15 @@ export function UsersDataTable<TData, TValue>({
|
||||
columns,
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="Server Users"
|
||||
searchPlaceholder="Search server users..."
|
||||
title={t('userServer')}
|
||||
searchPlaceholder={t('userSearch')}
|
||||
searchColumn="email"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ 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";
|
||||
|
||||
export type GlobalUserRow = {
|
||||
id: string;
|
||||
@@ -29,6 +30,7 @@ type Props = {
|
||||
|
||||
export default function UsersTable({ users }: Props) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<GlobalUserRow | null>(null);
|
||||
@@ -39,11 +41,11 @@ export default function UsersTable({ users }: Props) {
|
||||
const deleteUser = (id: string) => {
|
||||
api.delete(`/user/${id}`)
|
||||
.catch((e) => {
|
||||
console.error("Error deleting user", e);
|
||||
console.error(t('userErrorDelete'), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error deleting user",
|
||||
description: formatAxiosError(e, "Error deleting user")
|
||||
title: t('userErrorDelete'),
|
||||
description: formatAxiosError(e, t('userErrorDelete'))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
@@ -82,7 +84,7 @@ export default function UsersTable({ users }: Props) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Username
|
||||
{t('username')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -98,7 +100,7 @@ export default function UsersTable({ users }: Props) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Email
|
||||
{t('email')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -114,7 +116,7 @@ export default function UsersTable({ users }: Props) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
{t('name')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -130,7 +132,7 @@ export default function UsersTable({ users }: Props) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Identity Provider
|
||||
{t('identityProvider')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -151,7 +153,7 @@ export default function UsersTable({ users }: Props) {
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -172,35 +174,26 @@ export default function UsersTable({ users }: Props) {
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to permanently delete{" "}
|
||||
<b className="break-all">
|
||||
{selected?.email ||
|
||||
selected?.name ||
|
||||
selected?.username}
|
||||
</b>{" "}
|
||||
from the server?
|
||||
{t('userQuestionRemove', {selectedUser: selected?.email || selected?.name || selected?.username})}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>
|
||||
The user will be removed from all
|
||||
organizations and be completely removed from
|
||||
the server.
|
||||
{t('userMessageRemove')}
|
||||
</b>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the name of the user
|
||||
below.
|
||||
{t('userMessageConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete User"
|
||||
buttonText={t('userDeleteConfirm')}
|
||||
onConfirm={async () => deleteUser(selected!.id)}
|
||||
string={
|
||||
selected.email || selected.name || selected.username
|
||||
}
|
||||
title="Delete User from Server"
|
||||
title={t('userDeleteServer')}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AdminListUsersResponse } from "@server/routers/user/adminListUsers";
|
||||
import UsersTable, { GlobalUserRow } from "./AdminUsersTable";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
@@ -25,6 +26,8 @@ export default async function UsersPage(props: PageProps) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
const userRows: GlobalUserRow[] = rows.map((row) => {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -33,7 +36,7 @@ export default async function UsersPage(props: PageProps) {
|
||||
username: row.username,
|
||||
type: row.type,
|
||||
idpId: row.idpId,
|
||||
idpName: row.idpName || "Internal",
|
||||
idpName: row.idpName || t('idpNameInternal'),
|
||||
dateCreated: row.dateCreated,
|
||||
serverAdmin: row.serverAdmin
|
||||
};
|
||||
@@ -42,14 +45,14 @@ export default async function UsersPage(props: PageProps) {
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage All Users"
|
||||
description="View and manage all users in the system"
|
||||
title={t('userTitle')}
|
||||
description={t('userDescription')}
|
||||
/>
|
||||
<Alert variant="neutral" className="mb-6">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">About User Management</AlertTitle>
|
||||
<AlertTitle className="font-semibold">{t('userAbount')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
This table displays all root user objects in the system. Each user may belong to multiple organizations. Removing a user from an organization does not delete their root user object - they will remain in the system. To completely remove a user from the system, you must delete their root user object using the delete action in this table.
|
||||
{t('userAbountDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<UsersTable users={userRows} />
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
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;
|
||||
@@ -36,11 +37,13 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
|
||||
const { licenseStatus, isLicenseViolation } = useLicenseStatusContext();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
async function validate() {
|
||||
setLoading(true);
|
||||
|
||||
console.log("Validating OIDC token", {
|
||||
console.log(t('idpOidcTokenValidating'), {
|
||||
code: props.code,
|
||||
expectedState: props.expectedState,
|
||||
stateCookie: props.stateCookie
|
||||
@@ -59,7 +62,7 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
storedState: props.stateCookie
|
||||
});
|
||||
|
||||
console.log("Validate OIDC token response", res.data);
|
||||
console.log(t('idpOidcTokenResponse'), res.data);
|
||||
|
||||
const redirectUrl = res.data.data.redirectUrl;
|
||||
|
||||
@@ -76,7 +79,7 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
router.push(res.data.data.redirectUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(formatAxiosError(e, "Error validating OIDC token"));
|
||||
setError(formatAxiosError(e, t('idpErrorOidcTokenValidating')));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -89,20 +92,20 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Connecting to {props.idp.name}</CardTitle>
|
||||
<CardDescription>Validating your identity</CardDescription>
|
||||
<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>Connecting...</span>
|
||||
<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>Connected</span>
|
||||
<span>{t('idpConnectingToFinished')}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
@@ -110,9 +113,7 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<AlertDescription className="flex flex-col space-y-2">
|
||||
<span>
|
||||
There was a problem connecting to{" "}
|
||||
{props.idp.name}. Please contact your
|
||||
administrator.
|
||||
{t('idpErrorConnectingTo', {name: props.idp.name})}
|
||||
</span>
|
||||
<span className="text-xs">{error}</span>
|
||||
</AlertDescription>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cache } from "react";
|
||||
import { priv } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { GetIdpResponse } from "@server/routers/idp";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -16,6 +17,7 @@ export default async function Page(props: {
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
const t = await getTranslations();
|
||||
|
||||
const allCookies = await cookies();
|
||||
const stateCookie = allCookies.get("p_oidc_state")?.value;
|
||||
@@ -28,7 +30,7 @@ export default async function Page(props: {
|
||||
const foundIdp = idpRes.data?.data?.idp;
|
||||
|
||||
if (!foundIdp) {
|
||||
return <div>IdP not found</div>;
|
||||
return <div>{t('idpErrorNotFound')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AxiosResponse } from "axios";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Metadata } from "next";
|
||||
import { cache } from "react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Auth - Pangolin`,
|
||||
@@ -21,6 +22,7 @@ type AuthLayoutProps = {
|
||||
export default async function AuthLayout({ children }: AuthLayoutProps) {
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
const t = await getTranslations();
|
||||
|
||||
const licenseStatusRes = await cache(
|
||||
async () =>
|
||||
@@ -71,7 +73,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
||||
aria-label="GitHub"
|
||||
className="flex items-center space-x-2 whitespace-nowrap"
|
||||
>
|
||||
<span>Community Edition</span>
|
||||
<span>{t('communityEdition')}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type DashboardLoginFormProps = {
|
||||
redirect?: string;
|
||||
@@ -38,23 +39,25 @@ export default function DashboardLoginForm({
|
||||
// logout();
|
||||
// });
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<Image
|
||||
src={`/logo/pangolin_orange.svg`}
|
||||
alt="Pangolin Logo"
|
||||
alt={t('pangolinLogoAlt')}
|
||||
width={100}
|
||||
height={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<h1 className="text-2xl font-bold mt-1">
|
||||
Welcome to Pangolin
|
||||
{t('welcome')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Log in to get started
|
||||
{t('loginStart')}
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { LoginFormIDP } from "@app/components/LoginForm";
|
||||
import { priv } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { ListIdpsResponse } from "@server/routers/idp";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -44,6 +45,8 @@ export default async function Page(props: {
|
||||
name: idp.name
|
||||
})) as LoginFormIDP[];
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
{isInvite && (
|
||||
@@ -51,11 +54,10 @@ export default async function Page(props: {
|
||||
<div className="flex flex-col items-center">
|
||||
<Mail className="w-12 h-12 mb-4 text-primary" />
|
||||
<h2 className="text-2xl font-bold mb-2 text-center">
|
||||
Looks like you've been invited!
|
||||
{t('inviteAlready')}
|
||||
</h2>
|
||||
<p className="text-center">
|
||||
To accept the invite, you must log in or create an
|
||||
account.
|
||||
{t('inviteAlreadyDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,7 +67,7 @@ export default async function Page(props: {
|
||||
|
||||
{(!signUpDisabled || isInvite) && (
|
||||
<p className="text-center text-muted-foreground mt-4">
|
||||
Don't have an account?{" "}
|
||||
{t('authNoAccount')}{" "}
|
||||
<Link
|
||||
href={
|
||||
!redirectUrl
|
||||
@@ -74,7 +76,7 @@ export default async function Page(props: {
|
||||
}
|
||||
className="underline"
|
||||
>
|
||||
Sign up
|
||||
{t('signup')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -44,27 +44,12 @@ 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()
|
||||
});
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
email: z.string().email({ message: "Invalid email address" }),
|
||||
token: z.string().min(8, { message: "Invalid token" }),
|
||||
password: passwordSchema,
|
||||
confirmPassword: passwordSchema
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: "Passwords do not match"
|
||||
});
|
||||
|
||||
const mfaSchema = z.object({
|
||||
code: z.string().length(6, { message: "Invalid code" })
|
||||
});
|
||||
|
||||
export type ResetPasswordFormProps = {
|
||||
emailParam?: string;
|
||||
tokenParam?: string;
|
||||
@@ -81,6 +66,7 @@ export default function ResetPasswordForm({
|
||||
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) {
|
||||
@@ -98,6 +84,22 @@ export default function ResetPasswordForm({
|
||||
|
||||
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: {
|
||||
@@ -135,8 +137,8 @@ export default function ResetPasswordForm({
|
||||
} as RequestPasswordResetBody
|
||||
)
|
||||
.catch((e) => {
|
||||
setError(formatAxiosError(e, "An error occurred"));
|
||||
console.error("Failed to request reset:", e);
|
||||
setError(formatAxiosError(e, t('errorOccurred')));
|
||||
console.error(t('passwordErrorRequestReset'), e);
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
|
||||
@@ -165,8 +167,8 @@ export default function ResetPasswordForm({
|
||||
} as ResetPasswordBody
|
||||
)
|
||||
.catch((e) => {
|
||||
setError(formatAxiosError(e, "An error occurred"));
|
||||
console.error("Failed to reset password:", e);
|
||||
setError(formatAxiosError(e, t('errorOccurred')));
|
||||
console.error(t('passwordErrorReset'), e);
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
|
||||
@@ -182,7 +184,7 @@ export default function ResetPasswordForm({
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccessMessage("Password reset successfully! Back to log in...");
|
||||
setSuccessMessage(t('passwordResetSuccess'));
|
||||
|
||||
setTimeout(() => {
|
||||
if (redirect) {
|
||||
@@ -200,9 +202,9 @@ export default function ResetPasswordForm({
|
||||
<div>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Reset Password</CardTitle>
|
||||
<CardTitle>{t('passwordReset')}</CardTitle>
|
||||
<CardDescription>
|
||||
Follow the steps to reset your password
|
||||
{t('passwordResetDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -221,14 +223,13 @@ export default function ResetPasswordForm({
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormLabel>{t('email')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
We'll send a password reset
|
||||
code to this email address.
|
||||
{t('passwordResetSent')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -249,7 +250,7 @@ export default function ResetPasswordForm({
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormLabel>{t('email')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
@@ -268,7 +269,7 @@ export default function ResetPasswordForm({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Reset Code
|
||||
{t('passwordResetCode')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -278,8 +279,7 @@ export default function ResetPasswordForm({
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
Check your email for the
|
||||
reset code.
|
||||
{t('passwordResetCodeDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -292,7 +292,7 @@ export default function ResetPasswordForm({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
New Password
|
||||
{t('passwordNew')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -310,7 +310,7 @@ export default function ResetPasswordForm({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Confirm New Password
|
||||
{t('passwordNewConfirm')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -339,7 +339,7 @@ export default function ResetPasswordForm({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Authenticator Code
|
||||
{t('pincodeAuth')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
@@ -407,8 +407,8 @@ export default function ResetPasswordForm({
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{state === "reset"
|
||||
? "Reset Password"
|
||||
: "Submit Code"}
|
||||
? t('passwordReset')
|
||||
: t('pincodeSubmit2')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -422,7 +422,7 @@ export default function ResetPasswordForm({
|
||||
{isSubmitting && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Request Reset
|
||||
{t('passwordResetSubmit')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -436,7 +436,7 @@ export default function ResetPasswordForm({
|
||||
mfaForm.reset();
|
||||
}}
|
||||
>
|
||||
Back to Password
|
||||
{t('passwordBack')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -450,7 +450,7 @@ export default function ResetPasswordForm({
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
Back to Email
|
||||
{t('backToEmail')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cache } from "react";
|
||||
import ResetPasswordForm from "./ResetPasswordForm";
|
||||
import Link from "next/link";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -17,6 +18,7 @@ export default async function Page(props: {
|
||||
const searchParams = await props.searchParams;
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
const t = await getTranslations();
|
||||
|
||||
if (user) {
|
||||
redirect("/");
|
||||
@@ -44,7 +46,7 @@ export default async function Page(props: {
|
||||
}
|
||||
className="underline"
|
||||
>
|
||||
Go back to log in
|
||||
{t('loginBack')}
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
|
||||
@@ -13,6 +13,7 @@ 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;
|
||||
@@ -29,6 +30,8 @@ export default function AccessToken({
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
function appendRequestToken(url: string, token: string) {
|
||||
const fullUrl = new URL(url);
|
||||
fullUrl.searchParams.append(
|
||||
@@ -76,7 +79,7 @@ export default function AccessToken({
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error checking access token", e);
|
||||
console.error(t('accessTokenError'), e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -99,7 +102,7 @@ export default function AccessToken({
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error checking access token", e);
|
||||
console.error(t('accessTokenError'), e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -115,9 +118,9 @@ export default function AccessToken({
|
||||
|
||||
function renderTitle() {
|
||||
if (isValid) {
|
||||
return "Access Granted";
|
||||
return t('accessGranted');
|
||||
} else {
|
||||
return "Access URL Invalid";
|
||||
return t('accessUrlInvalid');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,18 +128,16 @@ export default function AccessToken({
|
||||
if (isValid) {
|
||||
return (
|
||||
<div>
|
||||
You have been granted access to this resource. Redirecting
|
||||
you...
|
||||
{t('accessGrantedDescription')}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
This shared access URL is invalid. Please contact the
|
||||
resource owner for a new URL.
|
||||
{t('accessUrlInvalidDescription')}
|
||||
<div className="text-center mt-4">
|
||||
<Button>
|
||||
<Link href="/">Go Home</Link>
|
||||
<Link href="/">{t('goHome')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,21 +9,23 @@ import {
|
||||
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">
|
||||
Access Denied
|
||||
{t('accessDenied')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
You're not allowed to access this resource. If this is a mistake,
|
||||
please contact the administrator.
|
||||
{t('accessDeniedDescription')}
|
||||
<div className="text-center mt-4">
|
||||
<Button>
|
||||
<Link href="/">Go Home</Link>
|
||||
<Link href="/">{t('goHome')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -44,6 +44,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import Link from "next/link";
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const pinSchema = z.object({
|
||||
pin: z
|
||||
@@ -86,6 +87,7 @@ type ResourceAuthPortalProps = {
|
||||
|
||||
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const getNumMethods = () => {
|
||||
let colLength = 0;
|
||||
@@ -183,8 +185,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
setOtpState("otp_sent");
|
||||
submitOtpForm.setValue("email", values.email);
|
||||
toast({
|
||||
title: "OTP Sent",
|
||||
description: "An OTP has been sent to your email"
|
||||
title: t('otpEmailSent'),
|
||||
description: t('otpEmailSentDescription')
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -200,7 +202,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setWhitelistError(
|
||||
formatAxiosError(e, "Failed to authenticate with email")
|
||||
formatAxiosError(e, t('otpEmailErrorAuthenticate'))
|
||||
);
|
||||
})
|
||||
.then(() => setLoadingLogin(false));
|
||||
@@ -225,7 +227,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setPincodeError(
|
||||
formatAxiosError(e, "Failed to authenticate with pincode")
|
||||
formatAxiosError(e, t('pincodeErrorAuthenticate'))
|
||||
);
|
||||
})
|
||||
.then(() => setLoadingLogin(false));
|
||||
@@ -253,7 +255,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setPasswordError(
|
||||
formatAxiosError(e, "Failed to authenticate with password")
|
||||
formatAxiosError(e, t('passwordErrorAuthenticate'))
|
||||
);
|
||||
})
|
||||
.finally(() => setLoadingLogin(false));
|
||||
@@ -280,7 +282,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
<div>
|
||||
<div className="text-center mb-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Powered by{" "}
|
||||
{t('poweredBy')}{" "}
|
||||
<Link
|
||||
href="https://github.com/fosrl/pangolin"
|
||||
target="_blank"
|
||||
@@ -293,11 +295,11 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Authentication Required</CardTitle>
|
||||
<CardTitle>{t('authenticationRequired')}</CardTitle>
|
||||
<CardDescription>
|
||||
{numMethods > 1
|
||||
? `Choose your preferred method to access ${props.resource.name}`
|
||||
: `You must authenticate to access ${props.resource.name}`}
|
||||
? t('authenticationMethodChoose', {name: props.resource.name})
|
||||
: t('authenticationRequest', {name: props.resource.name})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -327,19 +329,19 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
{props.methods.password && (
|
||||
<TabsTrigger value="password">
|
||||
<Key className="w-4 h-4 mr-1" />{" "}
|
||||
Password
|
||||
{t('password')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{props.methods.sso && (
|
||||
<TabsTrigger value="sso">
|
||||
<User className="w-4 h-4 mr-1" />{" "}
|
||||
User
|
||||
{t('user')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{props.methods.whitelist && (
|
||||
<TabsTrigger value="whitelist">
|
||||
<AtSign className="w-4 h-4 mr-1" />{" "}
|
||||
Email
|
||||
{t('email')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
@@ -362,7 +364,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
6-digit PIN Code
|
||||
{t('pincodeInput')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
@@ -431,7 +433,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
disabled={loadingLogin}
|
||||
>
|
||||
<LockIcon className="w-4 h-4 mr-2" />
|
||||
Log in with PIN
|
||||
{t('pincodeSubmit')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -457,7 +459,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Password
|
||||
{t('password')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -485,7 +487,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
disabled={loadingLogin}
|
||||
>
|
||||
<LockIcon className="w-4 h-4 mr-2" />
|
||||
Log In with Password
|
||||
{t('passwordSubmit')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -526,7 +528,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Email
|
||||
{t('email')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -535,10 +537,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A one-time
|
||||
code will be
|
||||
sent to this
|
||||
email.
|
||||
{t('otpEmailDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -560,7 +559,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
disabled={loadingLogin}
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Send One-time Code
|
||||
{t('otpEmailSend')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -582,9 +581,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
One-Time
|
||||
Password
|
||||
(OTP)
|
||||
{t('otpEmail')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -612,7 +609,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
disabled={loadingLogin}
|
||||
>
|
||||
<LockIcon className="w-4 h-4 mr-2" />
|
||||
Submit OTP
|
||||
{t('otpEmailSubmit')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -624,7 +621,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
submitOtpForm.reset();
|
||||
}}
|
||||
>
|
||||
Back to Email
|
||||
{t('backToEmail')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -637,9 +634,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
{supporterStatus?.visible && (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm text-muted-foreground opacity-50">
|
||||
Server is running without a supporter key.
|
||||
<br />
|
||||
Consider supporting the project!
|
||||
{t('noSupportKey')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,20 +7,24 @@ import {
|
||||
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">
|
||||
Resource Not Found
|
||||
{t('resourceNotFound')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
The resource you're trying to access does not exist.
|
||||
{t('resourceNotFoundDescription')}
|
||||
<div className="text-center mt-4">
|
||||
<Button>
|
||||
<Link href="/">Go Home</Link>
|
||||
<Link href="/">{t('goHome')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -31,6 +31,7 @@ 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";
|
||||
|
||||
type SignupFormProps = {
|
||||
redirect?: string;
|
||||
@@ -70,6 +71,8 @@ export default function SignupForm({
|
||||
}
|
||||
});
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
const { email, password } = values;
|
||||
|
||||
@@ -84,7 +87,7 @@ export default function SignupForm({
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setError(
|
||||
formatAxiosError(e, "An error occurred while signing up")
|
||||
formatAxiosError(e, t('signupError'))
|
||||
);
|
||||
});
|
||||
|
||||
@@ -118,17 +121,17 @@ export default function SignupForm({
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<Image
|
||||
src={`/logo/pangolin_orange.svg`}
|
||||
alt="Pangolin Logo"
|
||||
alt={t('pangolinLogoAlt')}
|
||||
width="100"
|
||||
height="100"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<h1 className="text-2xl font-bold mt-1">
|
||||
Welcome to Pangolin
|
||||
{t('welcome')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create an account to get started
|
||||
{t('authCreateAccount')}
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -143,7 +146,7 @@ export default function SignupForm({
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormLabel>{t('email')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
@@ -156,7 +159,7 @@ export default function SignupForm({
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormLabel>{t('password')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
@@ -172,7 +175,7 @@ export default function SignupForm({
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm Password</FormLabel>
|
||||
<FormLabel>{t('confirmPassword')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
@@ -191,7 +194,7 @@ export default function SignupForm({
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
Create Account
|
||||
{t('createAccount')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Mail } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -15,6 +16,7 @@ export default async function Page(props: {
|
||||
const searchParams = await props.searchParams;
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
const t = await getTranslations();
|
||||
|
||||
const env = pullEnv();
|
||||
|
||||
@@ -54,11 +56,10 @@ export default async function Page(props: {
|
||||
<div className="flex flex-col items-center">
|
||||
<Mail className="w-12 h-12 mb-4 text-primary" />
|
||||
<h2 className="text-2xl font-bold mb-2 text-center">
|
||||
Looks like you've been invited!
|
||||
{t('inviteAlready')}
|
||||
</h2>
|
||||
<p className="text-center">
|
||||
To accept the invite, you must log in or create an
|
||||
account.
|
||||
{t('inviteAlreadyDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,7 +72,7 @@ export default async function Page(props: {
|
||||
/>
|
||||
|
||||
<p className="text-center text-muted-foreground mt-4">
|
||||
Already have an account?{" "}
|
||||
{t('signupQuestion')}{" "}
|
||||
<Link
|
||||
href={
|
||||
!redirectUrl
|
||||
@@ -80,7 +81,7 @@ export default async function Page(props: {
|
||||
}
|
||||
className="underline"
|
||||
>
|
||||
Log in
|
||||
{t('login')}
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
|
||||
@@ -37,13 +37,7 @@ import { formatAxiosError } from "@app/lib/api";;
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
|
||||
const FormSchema = z.object({
|
||||
email: z.string().email({ message: "Invalid email address" }),
|
||||
pin: z.string().min(8, {
|
||||
message: "Your verification code must be 8 characters.",
|
||||
}),
|
||||
});
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type VerifyEmailFormProps = {
|
||||
email: string;
|
||||
@@ -55,6 +49,7 @@ export default function VerifyEmailForm({
|
||||
redirect,
|
||||
}: VerifyEmailFormProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
@@ -63,6 +58,13 @@ export default function VerifyEmailForm({
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
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: {
|
||||
@@ -79,15 +81,15 @@ export default function VerifyEmailForm({
|
||||
code: data.pin,
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(formatAxiosError(e, "An error occurred"));
|
||||
console.error("Failed to verify email:", e);
|
||||
setError(formatAxiosError(e, t('errorOccurred')));
|
||||
console.error(t('emailErrorVerify'), e);
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
|
||||
if (res && res.data?.data?.valid) {
|
||||
setError(null);
|
||||
setSuccessMessage(
|
||||
"Email successfully verified! Redirecting you..."
|
||||
t('emailVerified')
|
||||
);
|
||||
setTimeout(() => {
|
||||
if (redirect) {
|
||||
@@ -105,17 +107,16 @@ export default function VerifyEmailForm({
|
||||
setIsResending(true);
|
||||
|
||||
const res = await api.post("/auth/verify-email/request").catch((e) => {
|
||||
setError(formatAxiosError(e, "An error occurred"));
|
||||
console.error("Failed to resend verification code:", e);
|
||||
setError(formatAxiosError(e, t('errorOccurred')));
|
||||
console.error(t('verificationCodeErrorResend'), e);
|
||||
});
|
||||
|
||||
if (res) {
|
||||
setError(null);
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "Verification code resent",
|
||||
description:
|
||||
"We've resent a verification code to your email address. Please check your inbox.",
|
||||
title: t('verificationCodeResend'),
|
||||
description: t('verificationCodeResendDescription'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -126,9 +127,9 @@ export default function VerifyEmailForm({
|
||||
<div>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Verify Email</CardTitle>
|
||||
<CardTitle>{t('emailVerify')}</CardTitle>
|
||||
<CardDescription>
|
||||
Enter the verification code sent to your email address.
|
||||
{t('emailVerifyDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -142,7 +143,7 @@ export default function VerifyEmailForm({
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormLabel>{t('email')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
@@ -159,7 +160,7 @@ export default function VerifyEmailForm({
|
||||
name="pin"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Verification Code</FormLabel>
|
||||
<FormLabel>{t('verificationCode')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
@@ -197,8 +198,7 @@ export default function VerifyEmailForm({
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
We sent a verification code to your
|
||||
email address.
|
||||
{t('verificationCodeEmailSent')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -226,7 +226,7 @@ export default function VerifyEmailForm({
|
||||
{isSubmitting && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Submit
|
||||
{t('submit')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -241,8 +241,8 @@ export default function VerifyEmailForm({
|
||||
disabled={isResending}
|
||||
>
|
||||
{isResending
|
||||
? "Resending..."
|
||||
: "Didn't receive a code? Click here to resend"}
|
||||
? t('emailVerifyResendProgress')
|
||||
: t('emailVerifyResend')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function LicenseViolation() {
|
||||
const { licenseStatus } = useLicenseStatusContext();
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
if (!licenseStatus || isDismissed) return null;
|
||||
|
||||
@@ -16,15 +18,14 @@ export default function LicenseViolation() {
|
||||
<div className="fixed bottom-0 left-0 right-0 w-full bg-red-500 text-white p-4 text-center z-50">
|
||||
<div className="flex justify-between items-center">
|
||||
<p>
|
||||
Invalid or expired license keys detected. Follow license
|
||||
terms to continue using all features.
|
||||
{t('componentsInvalidKey')}
|
||||
</p>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
className="hover:bg-yellow-500"
|
||||
onClick={() => setIsDismissed(true)}
|
||||
>
|
||||
Dismiss
|
||||
{t('dismiss')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,17 +42,14 @@ export default function LicenseViolation() {
|
||||
<div className="fixed bottom-0 left-0 right-0 w-full bg-yellow-500 text-black p-4 text-center z-50">
|
||||
<div className="flex justify-between items-center">
|
||||
<p>
|
||||
License Violation: This server is using{" "}
|
||||
{licenseStatus.usedSites} sites which exceeds its
|
||||
licensed limit of {licenseStatus.maxSites} sites. Follow
|
||||
license terms to continue using all features.
|
||||
{t('componentsLicenseViolation', {usedSites: licenseStatus.usedSites, maxSites: licenseStatus.maxSites})}
|
||||
</p>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
className="hover:bg-yellow-500"
|
||||
onClick={() => setIsDismissed(true)}
|
||||
>
|
||||
Dismiss
|
||||
{t('dismiss')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Plus } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -31,31 +33,31 @@ export default function OrganizationLanding({
|
||||
setSelectedOrg(orgId);
|
||||
};
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
function getDescriptionText() {
|
||||
if (organizations.length === 0) {
|
||||
if (!disableCreateOrg) {
|
||||
return "You are not currently a member of any organizations. Create an organization to get started.";
|
||||
return t('componentsErrorNoMemberCreate');
|
||||
} else {
|
||||
return "You are not currently a member of any organizations.";
|
||||
return t('componentsErrorNoMember');
|
||||
}
|
||||
}
|
||||
|
||||
return `You're a member of ${organizations.length} ${
|
||||
organizations.length === 1 ? "organization" : "organizations"
|
||||
}.`;
|
||||
return t('componentsMember', {count: organizations.length});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Welcome to Pangolin</CardTitle>
|
||||
<CardTitle>{t('welcome')}</CardTitle>
|
||||
<CardDescription>{getDescriptionText()}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{organizations.length === 0 ? (
|
||||
disableCreateOrg ? (
|
||||
<p className="text-center text-muted-foreground">
|
||||
You are not currently a member of any organizations.
|
||||
t('componentsErrorNoMember')
|
||||
</p>
|
||||
) : (
|
||||
<Link href="/setup">
|
||||
@@ -64,7 +66,7 @@ export default function OrganizationLanding({
|
||||
size="lg"
|
||||
>
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
Create an Organization
|
||||
{t('componentsCreateOrg')}
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
import React from "react";
|
||||
import confetti from "canvas-confetti";
|
||||
import { Star } from "lucide-react";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function SupporterMessage({ tier }: { tier: string }) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center space-x-2 whitespace-nowrap group">
|
||||
<span
|
||||
@@ -31,7 +35,7 @@ export default function SupporterMessage({ tier }: { tier: string }) {
|
||||
</span>
|
||||
<Star className="w-3 h-3"/>
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2 -top-10 hidden group-hover:block text-primary text-sm rounded-md border shadow-md px-4 py-2 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Thank you for supporting Pangolin as a {tier}!
|
||||
{t('componentsSupporterMessage', {tier: tier})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -118,3 +118,8 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
word-break: keep-all;
|
||||
white-space: normal;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
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";
|
||||
@@ -23,8 +24,8 @@ export default function InviteStatusCard({
|
||||
token,
|
||||
}: InviteStatusCardProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
async function goToLogin() {
|
||||
await api.post("/auth/logout", {});
|
||||
@@ -41,13 +42,12 @@ export default function InviteStatusCard({
|
||||
return (
|
||||
<div>
|
||||
<p className="text-center mb-4">
|
||||
We're sorry, but it looks like the invite you're trying
|
||||
to access has not been accepted or is no longer valid.
|
||||
{t('inviteErrorNotValid')}
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm space-y-2">
|
||||
<li>The invite may have expired</li>
|
||||
<li>The invite might have been revoked</li>
|
||||
<li>There could be a typo in the invite link</li>
|
||||
<li>{t('inviteErrorExpired')}</li>
|
||||
<li>{t('inviteErrorRevoked')}</li>
|
||||
<li>{t('inviteErrorTypo')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
@@ -55,11 +55,10 @@ export default function InviteStatusCard({
|
||||
return (
|
||||
<div>
|
||||
<p className="text-center mb-4">
|
||||
We're sorry, but it looks like the invite you're trying
|
||||
to access is not for this user.
|
||||
{t('inviteErrorUser')}
|
||||
</p>
|
||||
<p className="text-center">
|
||||
Please make sure you're logged in as the correct user.
|
||||
{t('inviteLoginUser')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -67,11 +66,10 @@ export default function InviteStatusCard({
|
||||
return (
|
||||
<div>
|
||||
<p className="text-center mb-4">
|
||||
We're sorry, but it looks like the invite you're trying
|
||||
to access is not for a user that exists.
|
||||
{t('inviteErrorNoUser')}
|
||||
</p>
|
||||
<p className="text-center">
|
||||
Please create an account first.
|
||||
{t('inviteCreateUser')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -86,15 +84,15 @@ export default function InviteStatusCard({
|
||||
router.push("/");
|
||||
}}
|
||||
>
|
||||
Go Home
|
||||
{t('goHome')}
|
||||
</Button>
|
||||
);
|
||||
} else if (type === "wrong_user") {
|
||||
return (
|
||||
<Button onClick={goToLogin}>Log In as a Different User</Button>
|
||||
<Button onClick={goToLogin}>{t('inviteLogInOtherUser')}</Button>
|
||||
);
|
||||
} else if (type === "user_does_not_exist") {
|
||||
return <Button onClick={goToSignup}>Create an Account</Button>;
|
||||
return <Button onClick={goToSignup}>{t('createAnAccount')}</Button>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +107,7 @@ export default function InviteStatusCard({
|
||||
/>
|
||||
</div> */}
|
||||
<CardTitle className="text-center text-2xl font-bold">
|
||||
Invite Not Accepted
|
||||
{t('inviteNotAccepted')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>{renderBody()}</CardContent>
|
||||
|
||||
@@ -5,7 +5,8 @@ import { AcceptInviteResponse } from "@server/routers/user";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { redirect } from "next/navigation";
|
||||
import InviteStatusCard from "./InviteStatusCard";
|
||||
import { formatAxiosError } from "@app/lib/api";;
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export default async function InvitePage(props: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
@@ -19,13 +20,14 @@ export default async function InvitePage(props: {
|
||||
}
|
||||
|
||||
const user = await verifySession();
|
||||
const t = await getTranslations();
|
||||
|
||||
const parts = tokenParam.split("-");
|
||||
if (parts.length !== 2) {
|
||||
return (
|
||||
<>
|
||||
<h1>Invalid Invite</h1>
|
||||
<p>The invite link is invalid.</p>
|
||||
<h1>{t('inviteInvalid')}</h1>
|
||||
<p>{t('inviteInvalidDescription')}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -52,15 +54,13 @@ export default async function InvitePage(props: {
|
||||
}
|
||||
|
||||
function cardType() {
|
||||
if (error.includes("Invite is not for this user")) {
|
||||
if (error.includes(t('inviteErrorWrongUser'))) {
|
||||
return "wrong_user";
|
||||
} else if (
|
||||
error.includes(
|
||||
"User does not exist. Please create an account first."
|
||||
)
|
||||
error.includes(t('inviteErrorUserNotExists'))
|
||||
) {
|
||||
return "user_does_not_exist";
|
||||
} else if (error.includes("You must be logged in to accept an invite")) {
|
||||
} else if (error.includes(t('inviteErrorLoginRequired'))) {
|
||||
return "not_logged_in";
|
||||
} else {
|
||||
return "rejected";
|
||||
|
||||
@@ -13,6 +13,8 @@ import LicenseStatusProvider from "@app/providers/LicenseStatusProvider";
|
||||
import { GetLicenseStatusResponse } from "@server/routers/license";
|
||||
import LicenseViolation from "./components/LicenseViolation";
|
||||
import { cache } from "react";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale } from "next-intl/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Dashboard - Pangolin`,
|
||||
@@ -30,6 +32,7 @@ export default async function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const env = pullEnv();
|
||||
const locale = await getLocale();
|
||||
|
||||
let supporterData = {
|
||||
visible: true
|
||||
@@ -50,31 +53,33 @@ export default async function RootLayout({
|
||||
const licenseStatus = licenseStatusRes.data.data;
|
||||
|
||||
return (
|
||||
<html suppressHydrationWarning>
|
||||
<html suppressHydrationWarning lang={locale}>
|
||||
<body className={`${font.className} h-screen overflow-hidden`}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<EnvProvider env={pullEnv()}>
|
||||
<LicenseStatusProvider licenseStatus={licenseStatus}>
|
||||
<SupportStatusProvider
|
||||
supporterStatus={supporterData}
|
||||
>
|
||||
{/* Main content */}
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<LicenseViolation />
|
||||
{children}
|
||||
<NextIntlClientProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<EnvProvider env={pullEnv()}>
|
||||
<LicenseStatusProvider licenseStatus={licenseStatus}>
|
||||
<SupportStatusProvider
|
||||
supporterStatus={supporterData}
|
||||
>
|
||||
{/* Main content */}
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<LicenseViolation />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SupportStatusProvider>
|
||||
</LicenseStatusProvider>
|
||||
</EnvProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</SupportStatusProvider>
|
||||
</LicenseStatusProvider>
|
||||
</EnvProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
|
||||
export const orgLangingNavItems: SidebarNavItem[] = [
|
||||
{
|
||||
title: "Overview",
|
||||
title: "sidebarOverview",
|
||||
href: "/{orgId}",
|
||||
icon: <Home className="h-4 w-4" />
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export const orgLangingNavItems: SidebarNavItem[] = [
|
||||
|
||||
export const rootNavItems: SidebarNavItem[] = [
|
||||
{
|
||||
title: "Home",
|
||||
title: "sidebarHome",
|
||||
href: "/",
|
||||
icon: <Home className="h-4 w-4" />
|
||||
}
|
||||
@@ -30,54 +30,54 @@ export const rootNavItems: SidebarNavItem[] = [
|
||||
|
||||
export const orgNavItems: SidebarNavItem[] = [
|
||||
{
|
||||
title: "Sites",
|
||||
title: "sidebarSites",
|
||||
href: "/{orgId}/settings/sites",
|
||||
icon: <Combine className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
title: "Resources",
|
||||
title: "sidebarResources",
|
||||
href: "/{orgId}/settings/resources",
|
||||
icon: <Waypoints className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
title: "Clients",
|
||||
title: "sidebarClients",
|
||||
href: "/{orgId}/settings/clients",
|
||||
icon: <Workflow className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
title: "Access Control",
|
||||
title: "sidebarAccessControl",
|
||||
href: "/{orgId}/settings/access",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
autoExpand: true,
|
||||
children: [
|
||||
{
|
||||
title: "Users",
|
||||
title: "sidebarUsers",
|
||||
href: "/{orgId}/settings/access/users",
|
||||
children: [
|
||||
{
|
||||
title: "Invitations",
|
||||
title: "sidebarInvitations",
|
||||
href: "/{orgId}/settings/access/invitations"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Roles",
|
||||
title: "sidebarRoles",
|
||||
href: "/{orgId}/settings/access/roles"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Shareable Links",
|
||||
title: "sidebarShareableLinks",
|
||||
href: "/{orgId}/settings/share-links",
|
||||
icon: <LinkIcon className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
title: "API Keys",
|
||||
title: "sidebarApiKeys",
|
||||
href: "/{orgId}/settings/api-keys",
|
||||
icon: <KeyRound className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
title: "sidebarSettings",
|
||||
href: "/{orgId}/settings/general",
|
||||
icon: <Settings className="h-4 w-4" />
|
||||
}
|
||||
@@ -85,22 +85,22 @@ export const orgNavItems: SidebarNavItem[] = [
|
||||
|
||||
export const adminNavItems: SidebarNavItem[] = [
|
||||
{
|
||||
title: "All Users",
|
||||
title: "sidebarAllUsers",
|
||||
href: "/admin/users",
|
||||
icon: <Users className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
title: "API Keys",
|
||||
title: "sidebarApiKeys",
|
||||
href: "/admin/api-keys",
|
||||
icon: <KeyRound className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
title: "Identity Providers",
|
||||
title: "sidebarIdentityProviders",
|
||||
href: "/admin/idp",
|
||||
icon: <Fingerprint className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
title: "License",
|
||||
title: "sidebarLicense",
|
||||
href: "/admin/license",
|
||||
icon: <TicketCheck className="h-4 w-4" />
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export default async function NotFound() {
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto p-3 md:mt-32 text-center">
|
||||
<h1 className="text-6xl font-bold mb-4">404</h1>
|
||||
<h2 className="text-2xl font-semibold text-neutral-500 mb-4">
|
||||
Page Not Found
|
||||
{t('pageNotFound')}
|
||||
</h2>
|
||||
<p className="text-neutral-500 dark:text-neutral-700 mb-8">
|
||||
Oops! The page you're looking for doesn't exist.
|
||||
{t('pageNotFoundDescription')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -29,23 +29,26 @@ import {
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import CreateSiteForm from "../[orgId]/settings/sites/CreateSiteForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type Step = "org" | "site" | "resources";
|
||||
|
||||
const orgSchema = z.object({
|
||||
orgName: z.string().min(1, { message: "Organization name is required" }),
|
||||
orgId: z.string().min(1, { message: "Organization ID is required" }),
|
||||
subnet: z.string().min(1, { message: "Subnet is required" })
|
||||
});
|
||||
|
||||
export default function StepperForm() {
|
||||
const [currentStep, setCurrentStep] = useState<Step>("org");
|
||||
const [orgIdTaken, setOrgIdTaken] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const orgSchema = z.object({
|
||||
orgName: z.string().min(1, { message: t('orgNameRequired') }),
|
||||
orgId: z.string().min(1, { message: t('orgIdRequired') }),
|
||||
subnet: z.string().min(1, { message: t('subnetRequired') })
|
||||
});
|
||||
|
||||
const orgForm = useForm<z.infer<typeof orgSchema>>({
|
||||
resolver: zodResolver(orgSchema),
|
||||
defaultValues: {
|
||||
@@ -132,7 +135,7 @@ export default function StepperForm() {
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError(
|
||||
formatAxiosError(e, "An error occurred while creating org")
|
||||
formatAxiosError(e, t('orgErrorCreate'))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,9 +146,9 @@ export default function StepperForm() {
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>New Organization</CardTitle>
|
||||
<CardTitle>{t('setupNewOrg')}</CardTitle>
|
||||
<CardDescription>
|
||||
Create your organization, site, and resources
|
||||
{t('setupCreate')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -168,7 +171,7 @@ export default function StepperForm() {
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Create Org
|
||||
{t('setupCreateOrg')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
@@ -188,7 +191,7 @@ export default function StepperForm() {
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Create Site
|
||||
{t('siteCreate')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
@@ -208,7 +211,7 @@ export default function StepperForm() {
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Create Resources
|
||||
{t('setupCreateResources')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -227,7 +230,7 @@ export default function StepperForm() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Organization Name
|
||||
{t('setupOrgName')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -254,8 +257,7 @@ export default function StepperForm() {
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the display name for
|
||||
your organization.
|
||||
{t('orgDisplayName')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -266,7 +268,7 @@ export default function StepperForm() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Organization ID
|
||||
{t('orgId')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -276,11 +278,7 @@ export default function StepperForm() {
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the unique
|
||||
identifier for your
|
||||
organization. This is
|
||||
separate from the display
|
||||
name.
|
||||
{t('setupIdentifierMessage')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -312,9 +310,7 @@ export default function StepperForm() {
|
||||
{orgIdTaken && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
Organization ID is already
|
||||
taken. Please choose a different
|
||||
one.
|
||||
{t('setupErrorIdentifier')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -337,7 +333,7 @@ export default function StepperForm() {
|
||||
orgIdTaken
|
||||
}
|
||||
>
|
||||
Create Organization
|
||||
{t('setupCreateOrg')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user