mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-25 14:26:39 +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} />
|
||||
|
||||
Reference in New Issue
Block a user