Merge branch 'dev' into clients-pops

This commit is contained in:
Owen 2025-06-11 11:13:40 -04:00
commit 459bc32f9d
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
149 changed files with 13888 additions and 5083 deletions

View file

@ -43,6 +43,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
import { Description } from "@radix-ui/react-toast";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
type InviteUserFormProps = {
open: boolean;
@ -67,9 +68,11 @@ export default function InviteUserForm({
const api = createApiClient(useEnvContext());
const t = useTranslations();
const formSchema = z.object({
string: z.string().refine((val) => val === string, {
message: "Invalid confirmation"
message: t('inviteErrorInvalidConfirmation')
})
});
@ -129,7 +132,7 @@ export default function InviteUserForm({
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button
type="submit"

View file

@ -45,7 +45,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Search, RefreshCw, Filter, Columns } from "lucide-react";
import { GetSiteResponse, Container } from "@server/routers/site";
import { useDockerSocket } from "@app/hooks/useDockerSocket";
import { FaDocker } from "react-icons/fa";
import { useTranslations } from "next-intl";
// Type definitions based on the JSON structure
@ -60,6 +60,8 @@ export const ContainersSelector: FC<ContainerSelectorProps> = ({
}) => {
const [open, setOpen] = useState(false);
const t = useTranslations();
const { isAvailable, containers, fetchContainers } = useDockerSocket(site);
useEffect(() => {
@ -87,15 +89,16 @@ export const ContainersSelector: FC<ContainerSelectorProps> = ({
className="text-sm text-primary hover:underline cursor-pointer"
onClick={() => setOpen(true)}
>
View Docker Containers
{t("viewDockerContainers")}
</a>
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-[75vw] max-h-[75vh] flex flex-col">
<CredenzaHeader>
<CredenzaTitle>Containers in {site.name}</CredenzaTitle>
<CredenzaTitle>
{t("containersIn", { siteName: site.name })}
</CredenzaTitle>
<CredenzaDescription>
Select any container to use as a hostname for this
target. Click a port to use a port.
{t("selectContainerDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@ -109,7 +112,7 @@ export const ContainersSelector: FC<ContainerSelectorProps> = ({
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
@ -132,6 +135,8 @@ const DockerContainersTable: FC<{
labels: false
});
const t = useTranslations();
useEffect(() => {
const timer = setTimeout(() => {
setGlobalFilter(searchInput);
@ -182,14 +187,14 @@ const DockerContainersTable: FC<{
const columns: ColumnDef<Container>[] = [
{
accessorKey: "name",
header: "Name",
header: t("containerName"),
cell: ({ row }) => (
<div className="font-medium">{row.original.name}</div>
)
},
{
accessorKey: "image",
header: "Image",
header: t("containerImage"),
cell: ({ row }) => (
<div className="text-sm text-muted-foreground">
{row.original.image}
@ -198,7 +203,7 @@ const DockerContainersTable: FC<{
},
{
accessorKey: "state",
header: "State",
header: t("containerState"),
cell: ({ row }) => (
<Badge
variant={
@ -213,7 +218,7 @@ const DockerContainersTable: FC<{
},
{
accessorKey: "networks",
header: "Networks",
header: t("containerNetworks"),
cell: ({ row }) => {
const networks = Object.keys(row.original.networks);
return (
@ -231,7 +236,7 @@ const DockerContainersTable: FC<{
},
{
accessorKey: "hostname",
header: "Hostname/IP",
header: t("containerHostnameIp"),
enableHiding: false,
cell: ({ row }) => (
<div className="text-sm font-mono">
@ -241,7 +246,7 @@ const DockerContainersTable: FC<{
},
{
accessorKey: "labels",
header: "Labels",
header: t("containerLabels"),
cell: ({ row }) => {
const labels = row.original.labels || {};
const labelEntries = Object.entries(labels);
@ -258,15 +263,14 @@ const DockerContainersTable: FC<{
size="sm"
className="h-6 px-2 text-xs hover:bg-muted"
>
{labelEntries.length} label
{labelEntries.length !== 1 ? "s" : ""}
{t("containerLabelsCount", { count: labelEntries.length })}
</Button>
</PopoverTrigger>
<PopoverContent side="top" align="start">
<ScrollArea className="w-64 h-64">
<div className="space-y-2">
<h4 className="font-medium text-sm">
Container Labels
{t("containerLabelsTitle")}
</h4>
<div className="space-y-1">
{labelEntries.map(([key, value]) => (
@ -275,7 +279,7 @@ const DockerContainersTable: FC<{
{key}
</div>
<div className="font-mono text-muted-foreground pl-2 break-all">
{value || "<empty>"}
{value || t("containerLabelEmpty")}
</div>
</div>
))}
@ -289,7 +293,7 @@ const DockerContainersTable: FC<{
},
{
accessorKey: "ports",
header: "Ports",
header: t("containerPorts"),
enableHiding: false,
cell: ({ row }) => {
const ports = getExposedPorts(row.original);
@ -312,7 +316,7 @@ const DockerContainersTable: FC<{
<Popover>
<PopoverTrigger asChild>
<Button variant="link" size="sm">
+{ports.length - 2} more
{t("containerPortsMore", { count: ports.length - 2 })}
</Button>
</PopoverTrigger>
<PopoverContent
@ -345,7 +349,7 @@ const DockerContainersTable: FC<{
},
{
id: "actions",
header: "Actions",
header: t("containerActions"),
cell: ({ row }) => {
const ports = getExposedPorts(row.original);
return (
@ -355,7 +359,7 @@ const DockerContainersTable: FC<{
onClick={() => onContainerSelect(row.original, ports[0])}
disabled={row.original.state !== "running"}
>
Select
{t("select")}
</Button>
);
}
@ -412,8 +416,7 @@ const DockerContainersTable: FC<{
containers.length > 0 ? (
<>
<p>
No containers found matching the current
filters.
{t("noContainersMatchingFilters")}
</p>
<div className="space-x-2">
{hideContainersWithoutPorts && (
@ -426,7 +429,7 @@ const DockerContainersTable: FC<{
)
}
>
Show containers without ports
{t("showContainersWithoutPorts")}
</Button>
)}
{hideStoppedContainers && (
@ -437,15 +440,14 @@ const DockerContainersTable: FC<{
setHideStoppedContainers(false)
}
>
Show stopped containers
{t("showStoppedContainers")}
</Button>
)}
</div>
</>
) : (
<p>
No containers found. Make sure Docker containers
are running.
{t("noContainersFound")}
</p>
)}
</div>
@ -461,7 +463,7 @@ const DockerContainersTable: FC<{
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={`Search across ${initialFilters.length} containers...`}
placeholder={t("searchContainersPlaceholder", { count: initialFilters.length })}
value={searchInput}
onChange={(event) =>
setSearchInput(event.target.value)
@ -471,12 +473,7 @@ const DockerContainersTable: FC<{
{searchInput &&
table.getFilteredRowModel().rows.length > 0 && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
{table.getFilteredRowModel().rows.length}{" "}
result
{table.getFilteredRowModel().rows.length !==
1
? "s"
: ""}
{t("searchResultsCount", { count: table.getFilteredRowModel().rows.length })}
</div>
)}
</div>
@ -489,7 +486,7 @@ const DockerContainersTable: FC<{
className="gap-2"
>
<Filter className="h-4 w-4" />
Filters
{t("filters")}
{(hideContainersWithoutPorts ||
hideStoppedContainers) && (
<span className="bg-primary text-primary-foreground rounded-full w-5 h-5 text-xs flex items-center justify-center">
@ -502,7 +499,7 @@ const DockerContainersTable: FC<{
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel>
Filter Options
{t("filterOptions")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
@ -511,13 +508,13 @@ const DockerContainersTable: FC<{
setHideContainersWithoutPorts
}
>
Ports
{t("filterPorts")}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={hideStoppedContainers}
onCheckedChange={setHideStoppedContainers}
>
Stopped
{t("filterStopped")}
</DropdownMenuCheckboxItem>
{(hideContainersWithoutPorts ||
hideStoppedContainers) && (
@ -537,7 +534,7 @@ const DockerContainersTable: FC<{
}}
className="w-full text-xs"
>
Clear all filters
{t("clearAllFilters")}
</Button>
</div>
</>
@ -553,12 +550,12 @@ const DockerContainersTable: FC<{
className="gap-2"
>
<Columns className="h-4 w-4" />
Columns
{t("columns")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuLabel>
Toggle Columns
{t("toggleColumns")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
@ -577,7 +574,7 @@ const DockerContainersTable: FC<{
}
>
{column.id === "hostname"
? "Hostname/IP"
? t("containerHostnameIp")
: column.id}
</DropdownMenuCheckboxItem>
);
@ -589,7 +586,7 @@ const DockerContainersTable: FC<{
variant="outline"
size="icon"
onClick={onRefresh}
title="Refresh containers list"
title={t("refreshContainersList")}
>
<RefreshCw className="h-4 w-4" />
</Button>
@ -644,10 +641,10 @@ const DockerContainersTable: FC<{
{searchInput && !globalFilter ? (
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Searching...
{t("searching")}
</div>
) : (
`No containers found matching "${globalFilter}".`
t("noContainersFoundMatching", { filter: globalFilter })
)}
</TableCell>
</TableRow>

View file

@ -3,6 +3,7 @@
import { useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Copy, Check } from "lucide-react";
import { useTranslations } from "next-intl";
type CopyTextBoxProps = {
text?: string;
@ -19,6 +20,7 @@ export default function CopyTextBox({
}: CopyTextBoxProps) {
const [isCopied, setIsCopied] = useState(false);
const textRef = useRef<HTMLPreElement>(null);
const t = useTranslations();
const copyToClipboard = async () => {
if (textRef.current) {
@ -27,7 +29,7 @@ export default function CopyTextBox({
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (err) {
console.error("Failed to copy text: ", err);
console.error(t('copyTextFailed'), err);
}
}
};
@ -52,7 +54,7 @@ export default function CopyTextBox({
type="button"
className="absolute top-0.5 right-0 z-10 bg-card"
onClick={copyToClipboard}
aria-label="Copy to clipboard"
aria-label={t('copyTextClipboard')}
>
{isCopied ? (
<Check className="h-4 w-4 text-green-500" />

View file

@ -1,6 +1,7 @@
import { Check, Copy } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useTranslations } from "next-intl";
type CopyToClipboardProps = {
text: string;
@ -22,6 +23,8 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
const displayValue = displayText ?? text;
const t = useTranslations();
return (
<div className="flex items-center space-x-2 max-w-full">
{isLink ? (
@ -60,7 +63,7 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
) : (
<Check className="text-green-500 h-4 w-4" />
)}
<span className="sr-only">Copy text</span>
<span className="sr-only">{t('copyText')}</span>
</button>
</div>
);

View file

@ -14,6 +14,7 @@ import {
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { useTranslations } from "next-intl";
interface DataTablePaginationProps<TData> {
table: Table<TData>;
@ -22,6 +23,8 @@ interface DataTablePaginationProps<TData> {
export function DataTablePagination<TData>({
table
}: DataTablePaginationProps<TData>) {
const t = useTranslations();
return (
<div className="flex items-center justify-between text-muted-foreground">
<div className="flex items-center space-x-2">
@ -48,8 +51,7 @@ export function DataTablePagination<TData>({
<div className="flex items-center space-x-3 lg:space-x-8">
<div className="flex items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
{t('paginator', {current: table.getState().pagination.pageIndex + 1, last: table.getPageCount()})}
</div>
<div className="flex items-center space-x-2">
<Button
@ -58,7 +60,7 @@ export function DataTablePagination<TData>({
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<span className="sr-only">{t('paginatorToFirst')}</span>
<DoubleArrowLeftIcon className="h-4 w-4" />
</Button>
<Button
@ -67,7 +69,7 @@ export function DataTablePagination<TData>({
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<span className="sr-only">{t('paginatorToPrevious')}</span>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<Button
@ -76,7 +78,7 @@ export function DataTablePagination<TData>({
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<span className="sr-only">{t('paginatorToNext')}</span>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
@ -87,7 +89,7 @@ export function DataTablePagination<TData>({
}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<span className="sr-only">{t('paginatorToLast')}</span>
<DoubleArrowRightIcon className="h-4 w-4" />
</Button>
</div>

View file

@ -32,11 +32,7 @@ import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { useUserContext } from "@app/hooks/useUserContext";
import { CheckCircle2 } from "lucide-react";
const disableSchema = z.object({
password: z.string().min(1, { message: "Password is required" }),
code: z.string().min(1, { message: "Code is required" })
});
import { useTranslations } from "next-intl";
type Disable2FaProps = {
open: boolean;
@ -52,6 +48,13 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
const api = createApiClient(useEnvContext());
const t = useTranslations();
const disableSchema = z.object({
password: z.string().min(1, { message: t('passwordRequired') }),
code: z.string().min(1, { message: t('verificationCodeRequired') })
});
const disableForm = useForm<z.infer<typeof disableSchema>>({
resolver: zodResolver(disableSchema),
defaultValues: {
@ -70,10 +73,10 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
} as Disable2faBody)
.catch((e) => {
toast({
title: "Unable to disable 2FA",
title: t('otpErrorDisable'),
description: formatAxiosError(
e,
"An error occurred while disabling 2FA"
t('otpErrorDisableDescription')
),
variant: "destructive"
});
@ -109,10 +112,10 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
Disable Two-factor Authentication
{t('otpRemove')}
</CredenzaTitle>
<CredenzaDescription>
Disable two-factor authentication for your account
{t('otpRemoveDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@ -129,7 +132,7 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input
type="password"
@ -147,7 +150,7 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
render={({ field }) => (
<FormItem>
<FormLabel>
Authenticator Code
{t('otpSetupSecretCode')}
</FormLabel>
<FormControl>
<Input {...field} />
@ -168,19 +171,17 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
size={48}
/>
<p className="font-semibold text-lg">
Two-Factor Authentication Disabled
{t('otpRemoveSuccess')}
</p>
<p>
Two-factor authentication has been disabled for
your account. You can enable it again at any
time.
{t('otpRemoveSuccessMessage')}
</p>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
{step === "password" && (
<Button
@ -189,7 +190,7 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
loading={loading}
disabled={loading}
>
Disable 2FA
{t('otpRemoveSubmit')}
</Button>
)}
</CredenzaFooter>

View file

@ -40,14 +40,7 @@ import { formatAxiosError } from "@app/lib/api";
import CopyTextBox from "@app/components/CopyTextBox";
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
import { useUserContext } from "@app/hooks/useUserContext";
const enableSchema = z.object({
password: z.string().min(1, { message: "Password is required" })
});
const confirmSchema = z.object({
code: z.string().length(6, { message: "Invalid code" })
});
import { useTranslations } from "next-intl";
type Enable2FaProps = {
open: boolean;
@ -67,6 +60,15 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
const { user, updateUser } = useUserContext();
const api = createApiClient(useEnvContext());
const t = useTranslations();
const enableSchema = z.object({
password: z.string().min(1, { message: t('passwordRequired') })
});
const confirmSchema = z.object({
code: z.string().length(6, { message: t('pincodeInvalid') })
});
const enableForm = useForm<z.infer<typeof enableSchema>>({
resolver: zodResolver(enableSchema),
@ -94,10 +96,10 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
)
.catch((e) => {
toast({
title: "Unable to enable 2FA",
title: t('otpErrorEnable'),
description: formatAxiosError(
e,
"An error occurred while enabling 2FA"
t('otpErrorEnableDescription')
),
variant: "destructive"
});
@ -121,10 +123,10 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
} as VerifyTotpBody)
.catch((e) => {
toast({
title: "Unable to enable 2FA",
title: t('otpErrorEnable'),
description: formatAxiosError(
e,
"An error occurred while enabling 2FA"
t('otpErrorEnableDescription')
),
variant: "destructive"
});
@ -141,14 +143,14 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
const handleVerify = () => {
if (verificationCode.length !== 6) {
setError("Please enter a 6-digit code");
setError(t('otpSetupCheckCode'));
return;
}
if (verificationCode === "123456") {
setSuccess(true);
setStep(3);
} else {
setError("Invalid code. Please try again.");
setError(t('otpSetupCheckCodeRetry'));
}
};
@ -176,10 +178,10 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
Enable Two-factor Authentication
{t('otpSetup')}
</CredenzaTitle>
<CredenzaDescription>
Secure your account with an extra layer of protection
{t('otpSetupDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@ -196,7 +198,7 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input
type="password"
@ -215,8 +217,7 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
{step === 2 && (
<div className="space-y-4">
<p>
Scan this QR code with your authenticator app or
enter the secret key manually:
{t('otpSetupScanQr')}
</p>
<div className="h-[250px] mx-auto flex items-center justify-center">
<QRCodeCanvas value={secretUri} size={200} />
@ -243,7 +244,7 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
render={({ field }) => (
<FormItem>
<FormLabel>
Authenticator Code
{t('otpSetupSecretCode')}
</FormLabel>
<FormControl>
<Input
@ -268,11 +269,10 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
size={48}
/>
<p className="font-semibold text-lg">
Two-Factor Authentication Enabled
{t('otpSetupSuccess')}
</p>
<p>
Your account is now more secure. Don't forget to
save your backup codes.
{t('otpSetupSuccessStoreBackupCodes')}
</p>
<div className="max-w-md mx-auto">
@ -298,7 +298,7 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
}
}}
>
Submit
{t('submit')}
</Button>
)}
</CredenzaFooter>

View file

@ -7,6 +7,7 @@ import { cn } from "@app/lib/cn";
import { buttonVariants } from "@/components/ui/button";
import { Badge } from "@app/components/ui/badge";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl";
export type HorizontalTabs = Array<{
title: string;
@ -29,6 +30,7 @@ export function HorizontalTabs({
const pathname = usePathname();
const params = useParams();
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const t = useTranslations();
function hydrateHref(href: string) {
return href
@ -87,7 +89,7 @@ export function HorizontalTabs({
variant="outlinePrimary"
className="ml-2"
>
Professional
{t('licenseBadge')}
</Badge>
)}
</div>

View file

@ -24,6 +24,7 @@ import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTheme } from "next-themes";
import { useTranslations } from "next-intl";
interface LayoutProps {
children: React.ReactNode;
@ -85,6 +86,8 @@ export function Layout({
setPath(getPath());
}, [theme, env]);
const t = useTranslations();
return (
<div className="flex flex-col h-screen overflow-hidden">
@ -109,11 +112,10 @@ export function Layout({
className="w-64 p-0 flex flex-col h-full"
>
<SheetTitle className="sr-only">
Navigation Menu
{t('navbar')}
</SheetTitle>
<SheetDescription className="sr-only">
Main navigation menu for the
application
{t('navbarDescription')}
</SheetDescription>
<div className="flex-1 overflow-y-auto">
<div className="p-4">
@ -139,7 +141,7 @@ export function Layout({
}
>
<Server className="h-4 w-4" />
Server Admin
{t('serverAdmin')}
</Link>
</div>
)}
@ -190,7 +192,7 @@ export function Layout({
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Documentation
{t('navbarDocsLink')}
</Link>
</div>
<div>
@ -222,7 +224,7 @@ export function Layout({
className="flex items-center gap-3 text-muted-foreground hover:text-foreground transition-colors px-3 py-2 rounded-md w-full"
>
<Server className="h-4 w-4" />
Server Admin
{t('serverAdmin')}
</Link>
</div>
)}
@ -239,8 +241,8 @@ export function Layout({
className="flex items-center justify-center gap-1"
>
{!isUnlocked()
? "Community Edition"
: "Commercial Edition"}
? t('communityEdition')
: t('commercialEdition')}
<ExternalLink size={12} />
</Link>
</div>

View file

@ -0,0 +1,55 @@
import { useLocale } from "next-intl";
import LocaleSwitcherSelect from './LocaleSwitcherSelect';
export default function LocaleSwitcher() {
const locale = useLocale();
return (
<LocaleSwitcherSelect
label="Select language"
defaultValue={locale}
items={[
{
value: 'en-US',
label: 'English'
},
{
value: 'fr-FR',
label: "Français"
},
{
value: 'de-DE',
label: 'Deutsch'
},
{
value: 'it-IT',
label: 'Italiano'
},
{
value: 'nl-NL',
label: 'Nederlands'
},
{
value: 'pl-PL',
label: 'Polski'
},
{
value: 'pt-PT',
label: 'Português'
},
{
value: 'es-ES',
label: 'Español'
},
{
value: 'tr-TR',
label: 'Türkçe'
},
{
value: 'zh-CN',
label: '简体中文'
}
]}
/>
);
}

View file

@ -0,0 +1,71 @@
'use client';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@app/components/ui/dropdown-menu';
import { Button } from '@app/components/ui/button';
import { Check, Globe, Languages } from 'lucide-react';
import clsx from 'clsx';
import { useTransition } from 'react';
import { Locale } from '@/i18n/config';
import { setUserLocale } from '@/services/locale';
type Props = {
defaultValue: string;
items: Array<{ value: string; label: string }>;
label: string;
};
export default function LocaleSwitcherSelect({
defaultValue,
items,
label
}: Props) {
const [isPending, startTransition] = useTransition();
function onChange(value: string) {
const locale = value as Locale;
startTransition(() => {
setUserLocale(locale);
});
}
const selected = items.find((item) => item.value === defaultValue);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={clsx(
'w-full rounded-sm h-8 gap-2 justify-start font-normal',
isPending && 'pointer-events-none'
)}
aria-label={label}
>
<Languages className="h-4 w-4" />
<span className="text-left flex-1">
{selected?.label ?? label}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[8rem]">
{items.map((item) => (
<DropdownMenuItem
key={item.value}
onClick={() => onChange(item.value)}
className="flex items-center gap-2"
>
{item.value === defaultValue && (
<Check className="h-4 w-4" />
)}
<span>{item.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -40,6 +40,7 @@ import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import Image from "next/image";
import { GenerateOidcUrlResponse } from "@server/routers/idp";
import { Separator } from "./ui/separator";
import { useTranslations } from "next-intl";
export type LoginFormIDP = {
idpId: number;
@ -52,17 +53,6 @@ type LoginFormProps = {
idps?: LoginFormIDP[];
};
const formSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
password: z
.string()
.min(8, { message: "Password must be at least 8 characters" })
});
const mfaSchema = z.object({
code: z.string().length(6, { message: "Invalid code" })
});
export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
const router = useRouter();
@ -76,6 +66,19 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
const [mfaRequested, setMfaRequested] = useState(false);
const t = useTranslations();
const formSchema = z.object({
email: z.string().email({ message: t('emailInvalid') }),
password: z
.string()
.min(8, { message: t('passwordRequirementsChars') })
});
const mfaSchema = z.object({
code: z.string().length(6, { message: t('pincodeInvalid') })
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
@ -106,7 +109,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
.catch((e) => {
console.error(e);
setError(
formatAxiosError(e, "An error occurred while logging in")
formatAxiosError(e, t('loginError'))
);
});
@ -151,7 +154,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
console.log(res);
if (!res) {
setError("An error occurred while logging in");
setError(t('loginError'));
return;
}
@ -177,7 +180,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormLabel>{t('email')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@ -192,7 +195,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input
type="password"
@ -209,7 +212,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
className="text-sm text-muted-foreground"
>
Forgot your password?
{t('passwordForgot')}
</Link>
</div>
</div>
@ -222,11 +225,10 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
<>
<div className="text-center">
<h3 className="text-lg font-medium">
Two-Factor Authentication
{t('otpAuth')}
</h3>
<p className="text-sm text-muted-foreground">
Enter the code from your authenticator app or one of
your single-use backup codes.
{t('otpAuthDescription')}
</p>
</div>
<Form {...mfaForm}>
@ -302,7 +304,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
loading={loading}
disabled={loading}
>
Submit Code
{t('otpAuthSubmit')}
</Button>
)}
@ -316,7 +318,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
disabled={loading}
>
<LockIcon className="w-4 h-4 mr-2" />
Log In
{t('login')}
</Button>
{hasIdp && (
@ -327,7 +329,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="px-2 bg-card text-muted-foreground">
Or continue with
{t('idpContinue')}
</span>
</div>
</div>
@ -360,7 +362,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
mfaForm.reset();
}}
>
Back to Log In
{t('otpAuthBack')}
</Button>
)}
</div>

View file

@ -22,6 +22,7 @@ import { Check, ChevronsUpDown, Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
interface OrgSelectorProps {
orgId?: string;
@ -33,6 +34,7 @@ export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
const [open, setOpen] = useState(false);
const router = useRouter();
const { env } = useEnvContext();
const t = useTranslations();
return (
<Popover open={open} onOpenChange={setOpen}>
@ -47,7 +49,7 @@ export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
<div className="flex items-center justify-between w-full">
<div className="flex flex-col items-start">
<span className="font-bold text-sm">
Organization
{t('org')}
</span>
<span className="text-sm text-muted-foreground">
{orgId
@ -56,7 +58,7 @@ export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
org.orgId ===
orgId
)?.name
: "None selected"}
: t('noneSelected')}
</span>
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
@ -65,14 +67,14 @@ export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
</PopoverTrigger>
<PopoverContent className="w-[180px] p-0">
<Command>
<CommandInput placeholder="Search..." />
<CommandInput placeholder={t('searchProgress')} />
<CommandEmpty>
No organizations found.
{t('orgNotFound2')}
</CommandEmpty>
{(!env.flags.disableUserCreateOrg ||
user.serverAdmin) && (
<>
<CommandGroup heading="Create">
<CommandGroup heading={t('create')}>
<CommandList>
<CommandItem
onSelect={(
@ -84,14 +86,14 @@ export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
}}
>
<Plus className="mr-2 h-4 w-4" />
New Organization
{t('setupNewOrg')}
</CommandItem>
</CommandList>
</CommandGroup>
<CommandSeparator />
</>
)}
<CommandGroup heading="Organizations">
<CommandGroup heading={t('orgs')}>
<CommandList>
{orgs?.map((org) => (
<CommandItem

View file

@ -7,6 +7,7 @@ import {
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
type PermissionsSelectBoxProps = {
root?: boolean;
@ -15,101 +16,103 @@ type PermissionsSelectBoxProps = {
};
function getActionsCategories(root: boolean) {
const t = useTranslations();
const actionsByCategory: Record<string, Record<string, string>> = {
Organization: {
"Get Organization": "getOrg",
"Update Organization": "updateOrg",
"Get Organization User": "getOrgUser",
"List Organization Domains": "listOrgDomains",
[t('actionGetOrg')]: "getOrg",
[t('actionUpdateOrg')]: "updateOrg",
[t('actionGetOrgUser')]: "getOrgUser",
[t('actionListOrgDomains')]: "listOrgDomains",
},
Site: {
"Create Site": "createSite",
"Delete Site": "deleteSite",
"Get Site": "getSite",
"List Sites": "listSites",
"Update Site": "updateSite",
"List Allowed Site Roles": "listSiteRoles"
[t('actionCreateSite')]: "createSite",
[t('actionDeleteSite')]: "deleteSite",
[t('actionGetSite')]: "getSite",
[t('actionListSites')]: "listSites",
[t('actionUpdateSite')]: "updateSite",
[t('actionListSiteRoles')]: "listSiteRoles"
},
Resource: {
"Create Resource": "createResource",
"Delete Resource": "deleteResource",
"Get Resource": "getResource",
"List Resources": "listResources",
"Update Resource": "updateResource",
"List Resource Users": "listResourceUsers",
"Set Resource Users": "setResourceUsers",
"Set Allowed Resource Roles": "setResourceRoles",
"List Allowed Resource Roles": "listResourceRoles",
"Set Resource Password": "setResourcePassword",
"Set Resource Pincode": "setResourcePincode",
"Set Resource Email Whitelist": "setResourceWhitelist",
"Get Resource Email Whitelist": "getResourceWhitelist"
[t('actionCreateResource')]: "createResource",
[t('actionDeleteResource')]: "deleteResource",
[t('actionGetResource')]: "getResource",
[t('actionListResource')]: "listResources",
[t('actionUpdateResource')]: "updateResource",
[t('actionListResourceUsers')]: "listResourceUsers",
[t('actionSetResourceUsers')]: "setResourceUsers",
[t('actionSetAllowedResourceRoles')]: "setResourceRoles",
[t('actionListAllowedResourceRoles')]: "listResourceRoles",
[t('actionSetResourcePassword')]: "setResourcePassword",
[t('actionSetResourcePincode')]: "setResourcePincode",
[t('actionSetResourceEmailWhitelist')]: "setResourceWhitelist",
[t('actionGetResourceEmailWhitelist')]: "getResourceWhitelist"
},
Target: {
"Create Target": "createTarget",
"Delete Target": "deleteTarget",
"Get Target": "getTarget",
"List Targets": "listTargets",
"Update Target": "updateTarget"
[t('actionCreateTarget')]: "createTarget",
[t('actionDeleteTarget')]: "deleteTarget",
[t('actionGetTarget')]: "getTarget",
[t('actionListTargets')]: "listTargets",
[t('actionUpdateTarget')]: "updateTarget"
},
Role: {
"Create Role": "createRole",
"Delete Role": "deleteRole",
"Get Role": "getRole",
"List Roles": "listRoles",
"Update Role": "updateRole",
"List Allowed Role Resources": "listRoleResources"
[t('actionCreateRole')]: "createRole",
[t('actionDeleteRole')]: "deleteRole",
[t('actionGetRole')]: "getRole",
[t('actionListRole')]: "listRoles",
[t('actionUpdateRole')]: "updateRole",
[t('actionListAllowedRoleResources')]: "listRoleResources"
},
User: {
"Invite User": "inviteUser",
"Remove User": "removeUser",
"List Users": "listUsers",
"Add User Role": "addUserRole"
[t('actionInviteUser')]: "inviteUser",
[t('actionRemoveUser')]: "removeUser",
[t('actionListUsers')]: "listUsers",
[t('actionAddUserRole')]: "addUserRole"
},
"Access Token": {
"Generate Access Token": "generateAccessToken",
"Delete Access Token": "deleteAcessToken",
"List Access Tokens": "listAccessTokens"
[t('actionGenerateAccessToken')]: "generateAccessToken",
[t('actionDeleteAccessToken')]: "deleteAcessToken",
[t('actionListAccessTokens')]: "listAccessTokens"
},
"Resource Rule": {
"Create Resource Rule": "createResourceRule",
"Delete Resource Rule": "deleteResourceRule",
"List Resource Rules": "listResourceRules",
"Update Resource Rule": "updateResourceRule"
[t('actionCreateResourceRule')]: "createResourceRule",
[t('actionDeleteResourceRule')]: "deleteResourceRule",
[t('actionListResourceRules')]: "listResourceRules",
[t('actionUpdateResourceRule')]: "updateResourceRule"
}
};
if (root) {
actionsByCategory["Organization"] = {
"List Organizations": "listOrgs",
"Check ID": "checkOrgId",
"Create Organization": "createOrg",
"Delete Organization": "deleteOrg",
"List API Keys": "listApiKeys",
"List API Key Actions": "listApiKeyActions",
"Set API Key Allowed Actions": "setApiKeyActions",
"Create API Key": "createApiKey",
"Delete API Key": "deleteApiKey",
[t('actionListOrgs')]: "listOrgs",
[t('actionCheckOrgId')]: "checkOrgId",
[t('actionCreateOrg')]: "createOrg",
[t('actionDeleteOrg')]: "deleteOrg",
[t('actionListApiKeys')]: "listApiKeys",
[t('actionListApiKeyActions')]: "listApiKeyActions",
[t('actionSetApiKeyActions')]: "setApiKeyActions",
[t('actionCreateApiKey')]: "createApiKey",
[t('actionDeleteApiKey')]: "deleteApiKey",
...actionsByCategory["Organization"]
};
actionsByCategory["Identity Provider (IDP)"] = {
"Create IDP": "createIdp",
"Update IDP": "updateIdp",
"Delete IDP": "deleteIdp",
"List IDP": "listIdps",
"Get IDP": "getIdp",
"Create IDP Org Policy": "createIdpOrg",
"Delete IDP Org Policy": "deleteIdpOrg",
"List IDP Orgs": "listIdpOrgs",
"Update IDP Org": "updateIdpOrg"
[t('actionCreateIdp')]: "createIdp",
[t('actionUpdateIdp')]: "updateIdp",
[t('actionDeleteIdp')]: "deleteIdp",
[t('actionListIdps')]: "listIdps",
[t('actionGetIdp')]: "getIdp",
[t('actionCreateIdpOrg')]: "createIdpOrg",
[t('actionDeleteIdpOrg')]: "deleteIdpOrg",
[t('actionListIdpOrgs')]: "listIdpOrgs",
[t('actionUpdateIdpOrg')]: "updateIdpOrg"
};
}
@ -160,13 +163,15 @@ export default function PermissionsSelectBox({
onChange(updated);
};
const t = useTranslations();
return (
<>
<div className="mb-4">
<CheckboxWithLabel
variant="outlinePrimarySquare"
id="toggle-all-permissions"
label="Allow All Permissions"
label={t('permissionsAllowAll')}
checked={allPermissionsChecked}
onCheckedChange={(checked) =>
toggleAllPermissions(checked as boolean)
@ -185,7 +190,7 @@ export default function PermissionsSelectBox({
<CheckboxWithLabel
variant="outlinePrimarySquare"
id={`toggle-all-${category}`}
label="Allow All"
label={t('allowAll')}
checked={allChecked}
onCheckedChange={(checked) =>
toggleAllInCategory(

View file

@ -1,6 +1,7 @@
"use client";
import { cn } from "@app/lib/cn";
import { useTranslations } from "next-intl";
type ProfessionalContentOverlayProps = {
children: React.ReactNode;
@ -11,6 +12,8 @@ export function ProfessionalContentOverlay({
children,
isProfessional = false
}: ProfessionalContentOverlayProps) {
const t = useTranslations();
return (
<div
className={cn(
@ -22,11 +25,10 @@ export function ProfessionalContentOverlay({
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-50">
<div className="text-center p-6 bg-primary/10 rounded-lg">
<h3 className="text-lg font-semibold mb-2">
Professional Edition Required
{t('licenseTierProfessionalRequired')}
</h3>
<p className="text-muted-foreground">
This feature is only available in the Professional
Edition.
{t('licenseTierProfessionalRequiredDescription')}
</p>
</div>
</div>

View file

@ -23,6 +23,9 @@ import Disable2FaForm from "./Disable2FaForm";
import Enable2FaForm from "./Enable2FaForm";
import SupporterStatus from "./SupporterStatus";
import { UserType } from "@server/types/UserTypes";
import LocaleSwitcher from '@app/components/LocaleSwitcher';
import { useTranslations } from "next-intl";
export default function ProfileIcon() {
const { setTheme, theme } = useTheme();
@ -38,6 +41,8 @@ export default function ProfileIcon() {
const [openEnable2fa, setOpenEnable2fa] = useState(false);
const [openDisable2fa, setOpenDisable2fa] = useState(false);
const t = useTranslations();
function getInitials() {
return (user.email || user.name || user.username)
.substring(0, 1)
@ -52,10 +57,10 @@ export default function ProfileIcon() {
function logout() {
api.post("/auth/logout")
.catch((e) => {
console.error("Error logging out", e);
console.error(t('logoutError'), e);
toast({
title: "Error logging out",
description: formatAxiosError(e, "Error logging out")
title: t('logoutError'),
description: formatAxiosError(e, t('logoutError'))
});
})
.then(() => {
@ -92,7 +97,7 @@ export default function ProfileIcon() {
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
Signed in as
{t('signingAs')}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email || user.name || user.username}
@ -100,11 +105,11 @@ export default function ProfileIcon() {
</div>
{user.serverAdmin ? (
<p className="text-xs leading-none text-muted-foreground mt-2">
Server Admin
{t('serverAdmin')}
</p>
) : (
<p className="text-xs leading-none text-muted-foreground mt-2">
{user.idpName || "Internal"}
{user.idpName || t('idpNameInternal')}
</p>
)}
</DropdownMenuLabel>
@ -115,20 +120,20 @@ export default function ProfileIcon() {
<DropdownMenuItem
onClick={() => setOpenEnable2fa(true)}
>
<span>Enable Two-factor</span>
<span>{t('otpEnable')}</span>
</DropdownMenuItem>
)}
{user.twoFactorEnabled && (
<DropdownMenuItem
onClick={() => setOpenDisable2fa(true)}
>
<span>Disable Two-factor</span>
<span>{t('otpDisable')}</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuLabel>Theme</DropdownMenuLabel>
<DropdownMenuLabel>{t("theme")}</DropdownMenuLabel>
{(["light", "dark", "system"] as const).map(
(themeOption) => (
<DropdownMenuItem
@ -147,7 +152,7 @@ export default function ProfileIcon() {
<Laptop className="mr-2 h-4 w-4" />
)}
<span className="capitalize">
{themeOption}
{t(themeOption)}
</span>
{userTheme === themeOption && (
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
@ -157,10 +162,12 @@ export default function ProfileIcon() {
</DropdownMenuItem>
)
)}
<DropdownMenuSeparator />
<LocaleSwitcher />
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout()}>
{/* <LogOut className="mr-2 h-4 w-4" /> */}
<span>Log Out</span>
<span>{t('logout')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -8,6 +8,7 @@ import { ChevronDown, ChevronRight } from "lucide-react";
import { useUserContext } from "@app/hooks/useUserContext";
import { Badge } from "@app/components/ui/badge";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl";
export interface SidebarNavItem {
href: string;
@ -66,6 +67,8 @@ export function SidebarNav({
const { user } = useUserContext();
const t = useTranslations();
function hydrateHref(val: string): string {
return val
.replace("{orgId}", orgId)
@ -139,14 +142,14 @@ export function SidebarNav({
{item.icon}
</span>
)}
{item.title}
{t(item.title)}
</div>
{isProfessional && (
<Badge
variant="outlinePrimary"
className="ml-2"
>
Professional
{t('licenseBadge')}
</Badge>
)}
</Link>

View file

@ -3,7 +3,7 @@
import Image from "next/image";
import { Separator } from "@app/components/ui/separator";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useState } from "react";
import { useState, useTransition } from "react";
import {
Popover,
PopoverContent,
@ -48,13 +48,7 @@ import {
} from "./ui/card";
import { Check, ExternalLink } from "lucide-react";
import confetti from "canvas-confetti";
const formSchema = z.object({
githubUsername: z
.string()
.nonempty({ message: "GitHub username is required" }),
key: z.string().nonempty({ message: "Supporter key is required" })
});
import { useTranslations } from "next-intl";
export default function SupporterStatus() {
const { supporterStatus, updateSupporterStatus } =
@ -64,6 +58,14 @@ export default function SupporterStatus() {
const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false);
const api = createApiClient(useEnvContext());
const t = useTranslations();
const formSchema = z.object({
githubUsername: z
.string()
.nonempty({ message: "GitHub username is required" }),
key: z.string().nonempty({ message: "Supporter key is required" })
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@ -95,8 +97,8 @@ export default function SupporterStatus() {
if (!data || !data.valid) {
toast({
variant: "destructive",
title: "Invalid Key",
description: "Your supporter key is invalid."
title: t('supportKeyInvalid'),
description: t('supportKeyInvalidDescription')
});
return;
}
@ -104,9 +106,8 @@ export default function SupporterStatus() {
// Trigger the toast
toast({
variant: "default",
title: "Valid Key",
description:
"Your supporter key has been validated. Thank you for your support!"
title: t('supportKeyValid'),
description: t('supportKeyValidDescription')
});
// Fireworks-style confetti
@ -162,10 +163,10 @@ export default function SupporterStatus() {
} catch (error) {
toast({
variant: "destructive",
title: "Error",
title: t('error'),
description: formatAxiosError(
error,
"Failed to validate supporter key."
t('supportKeyErrorValidationDescription')
)
});
return;
@ -183,55 +184,47 @@ export default function SupporterStatus() {
<CredenzaContent className="max-w-3xl">
<CredenzaHeader>
<CredenzaTitle>
Support Development and Adopt a Pangolin!
{t('supportKey')}
</CredenzaTitle>
</CredenzaHeader>
<CredenzaBody>
<p>
Purchase a supporter key to help us continue
developing Pangolin for the community. Your
contribution allows us to commit more time to
maintain and add new features to the application for
everyone. We will never use this to paywall
features. This is separate from any Commercial
Edition.
{t('supportKeyDescription')}
</p>
<p>
You will also get to adopt and meet your very own
pet Pangolin!
{t('supportKeyPet')}
</p>
<p>
Payments are processed via GitHub. Afterward, you
can retrieve your key on{" "}
{t('supportKeyPurchase')}{" "}
<Link
href="https://supporters.fossorial.io/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
our website
{t('supportKeyPurchaseLink')}
</Link>{" "}
and redeem it here.{" "}
{t('supportKeyPurchase2')}{" "}
<Link
href="https://docs.fossorial.io/supporter-program"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Learn more.
{t('supportKeyLearnMore')}
</Link>
</p>
<div className="py-6">
<p className="mb-3 text-center">
Please select the option that best suits you.
{t('supportKeyOptions')}
</p>
<div className="grid md:grid-cols-2 grid-cols-1 gap-8">
<Card>
<CardHeader>
<CardTitle>Full Supporter</CardTitle>
<CardTitle>{t('supportKetOptionFull')}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-4xl mb-6">$95</p>
@ -239,19 +232,19 @@ export default function SupporterStatus() {
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
For the whole server
{t('forWholeServer')}
</span>
</li>
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
Lifetime purchase
{t('lifetimePurchase')}
</span>
</li>
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
Supporter status
{t('supporterStatus')}
</span>
</li>
</ul>
@ -264,7 +257,7 @@ export default function SupporterStatus() {
className="w-full"
>
<Button className="w-full">
Buy
{t('buy')}
</Button>
</Link>
</CardFooter>
@ -274,7 +267,7 @@ export default function SupporterStatus() {
className={`${supporterStatus?.tier === "Limited Supporter" ? "opacity-50" : ""}`}
>
<CardHeader>
<CardTitle>Limited Supporter</CardTitle>
<CardTitle>{t('supportKeyOptionLimited')}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-4xl mb-6">$25</p>
@ -282,19 +275,19 @@ export default function SupporterStatus() {
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
For 5 or less users
{t('forFiveUsers')}
</span>
</li>
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
Lifetime purchase
{t('lifetimePurchase')}
</span>
</li>
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
Supporter status
{t('supporterStatus')}
</span>
</li>
</ul>
@ -309,7 +302,7 @@ export default function SupporterStatus() {
className="w-full"
>
<Button className="w-full">
Buy
{t('buy')}
</Button>
</Link>
) : (
@ -320,7 +313,7 @@ export default function SupporterStatus() {
"Limited Supporter"
}
>
Buy
{t('buy')}
</Button>
)}
</CardFooter>
@ -336,20 +329,20 @@ export default function SupporterStatus() {
setKeyOpen(true);
}}
>
Redeem Supporter Key
{t('supportKeyRedeem')}
</Button>
<Button
variant="ghost"
className="w-full"
onClick={() => hide()}
>
Hide for 7 days
{t('supportKeyHideSevenDays')}
</Button>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
@ -363,9 +356,9 @@ export default function SupporterStatus() {
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Enter Supporter Key</CredenzaTitle>
<CredenzaTitle>{t('supportKeyEnter')}</CredenzaTitle>
<CredenzaDescription>
Meet your very own pet Pangolin!
{t('supportKeyEnterDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@ -381,7 +374,7 @@ export default function SupporterStatus() {
render={({ field }) => (
<FormItem>
<FormLabel>
GitHub Username
{t('githubUsername')}
</FormLabel>
<FormControl>
<Input {...field} />
@ -395,7 +388,7 @@ export default function SupporterStatus() {
name="key"
render={({ field }) => (
<FormItem>
<FormLabel>Supporter Key</FormLabel>
<FormLabel>{t('supportKeyInput')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@ -408,10 +401,10 @@ export default function SupporterStatus() {
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button type="submit" form="form">
Submit
{t('submit')}
</Button>
</CredenzaFooter>
</CredenzaContent>
@ -426,7 +419,7 @@ export default function SupporterStatus() {
setPurchaseOptionsOpen(true);
}}
>
Buy Supporter Key
{t('supportKeyBuy')}
</Button>
) : null}
</>

View file

@ -4,6 +4,7 @@ import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Button } from "../ui/button";
import { cn } from "@app/lib/cn";
import { useTranslations } from "next-intl";
type AutocompleteProps = {
tags: TagType[];
@ -40,6 +41,7 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
const triggerRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const popoverContentRef = useRef<HTMLDivElement | null>(null);
const t = useTranslations();
const [popoverWidth, setPopoverWidth] = useState<number>(0);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
@ -342,7 +344,7 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
</div>
) : (
<div className="py-6 text-center text-sm">
No results found.
{t('noResults')}
</div>
)}
</div>

View file

@ -10,6 +10,7 @@ import { TagList } from "./tag-list";
import { tagVariants } from "./tag";
import { Autocomplete } from "./autocomplete";
import { cn } from "@app/lib/cn";
import { useTranslations } from "next-intl";
export enum Delimiter {
Comma = ",",
@ -166,11 +167,13 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
);
const inputRef = React.useRef<HTMLInputElement>(null);
const t = useTranslations();
if (
(maxTags !== undefined && maxTags < 0) ||
(props.minTags !== undefined && props.minTags < 0)
) {
console.warn("maxTags and minTags cannot be less than 0");
console.warn(t('tagsWarnCannotBeLessThanZero'));
// error
return null;
}
@ -194,24 +197,22 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
(option) => option.text === newTagText
)
) {
console.warn(
"Tag not allowed as per autocomplete options"
);
console.warn(t('tagsWarnNotAllowedAutocompleteOptions'));
return;
}
if (validateTag && !validateTag(newTagText)) {
console.warn("Invalid tag as per validateTag");
console.warn(t('tagsWarnInvalid'));
return;
}
if (minLength && newTagText.length < minLength) {
console.warn(`Tag "${newTagText}" is too short`);
console.warn(t('tagWarnTooShort', {tagText: newTagText}));
return;
}
if (maxLength && newTagText.length > maxLength) {
console.warn(`Tag "${newTagText}" is too long`);
console.warn(t('tagWarnTooLong', {tagText: newTagText}));
return;
}
@ -228,12 +229,10 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
setTags((prevTags) => [...prevTags, newTag]);
onTagAdd?.(newTagText);
} else {
console.warn(
"Reached the maximum number of tags allowed"
);
console.warn(t('tagsWarnReachedMaxNumber'));
}
} else {
console.warn(`Duplicate tag "${newTagText}" not added`);
console.warn(t('tagWarnDuplicate', {tagText: newTagText}));
}
});
setInputValue("");
@ -259,12 +258,12 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
}
if (minLength && newTagText.length < minLength) {
console.warn("Tag is too short");
console.warn(t('tagWarnTooShort'));
return;
}
if (maxLength && newTagText.length > maxLength) {
console.warn("Tag is too long");
console.warn(t('tagWarnTooLong'));
return;
}
@ -309,7 +308,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
}
if (minLength && newTagText.length < minLength) {
console.warn("Tag is too short");
console.warn(t('tagWarnTooShort'));
// error
return;
}
@ -317,7 +316,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
// Validate maxLength
if (maxLength && newTagText.length > maxLength) {
// error
console.warn("Tag is too long");
console.warn(t('tagWarnTooLong'));
return;
}

View file

@ -4,6 +4,7 @@ import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
import { TagList, TagListProps } from "./tag-list";
import { Button } from "../ui/button";
import { cn } from "@app/lib/cn";
import { useTranslations } from "next-intl";
type TagPopoverProps = {
children: React.ReactNode;
@ -41,6 +42,8 @@ export const TagPopover: React.FC<TagPopoverProps> = ({
const [inputFocused, setInputFocused] = useState(false);
const [sideOffset, setSideOffset] = useState<number>(0);
const t = useTranslations();
useEffect(() => {
const handleResize = () => {
if (triggerContainerRef.current && triggerRef.current) {
@ -183,10 +186,10 @@ export const TagPopover: React.FC<TagPopoverProps> = ({
>
<div className="space-y-1">
<h4 className="text-sm font-medium leading-none">
Entered Tags
{t('tagsEntered')}
</h4>
<p className="text-sm text-muted-foregrounsd text-left">
These are the tags you&apos;ve entered.
{t('tagsEnteredDescription')}
</p>
</div>
<TagList