mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-06-09 13:00:14 +00:00
feat: add new quick actions menu instead of individual dropdowns in frontend
This commit is contained in:
@@ -1,36 +0,0 @@
|
|||||||
import { languages, SupportedLanguage } from "@/lib/i18n/locales";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "../ui/select";
|
|
||||||
import { useState } from "react";
|
|
||||||
import i18n from "@/lib/i18n/i18n";
|
|
||||||
|
|
||||||
export const LanguageSelector = () => {
|
|
||||||
const [language, setLanguage] = useState<SupportedLanguage>(
|
|
||||||
i18n.language as SupportedLanguage,
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelect = (option: string) => {
|
|
||||||
setLanguage(option as SupportedLanguage);
|
|
||||||
i18n.changeLanguage(option as SupportedLanguage);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select onValueChange={handleSelect} value={language}>
|
|
||||||
<SelectTrigger aria-label="Select language">
|
|
||||||
<SelectValue placeholder="Select language" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{Object.entries(languages).map(([key, value]) => (
|
|
||||||
<SelectItem key={key} value={key}>
|
|
||||||
{value}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useAppContext } from "@/context/app-context";
|
import { useAppContext } from "@/context/app-context";
|
||||||
import { LanguageSelector } from "../language/language";
|
|
||||||
import { Outlet } from "react-router";
|
import { Outlet } from "react-router";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { DomainWarning } from "../domain-warning/domain-warning";
|
import { DomainWarning } from "../domain-warning/domain-warning";
|
||||||
import { ThemeToggle } from "../theme-toggle/theme-toggle";
|
import { QuickActions } from "../quick-actions/quick-actions";
|
||||||
|
|
||||||
const BaseLayout = ({ children }: { children: React.ReactNode }) => {
|
const BaseLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { ui } = useAppContext();
|
const { ui } = useAppContext();
|
||||||
@@ -21,9 +20,8 @@ const BaseLayout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
backgroundPosition: "center",
|
backgroundPosition: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute top-4 right-4 flex flex-row gap-2">
|
<div className="absolute top-4 right-4">
|
||||||
<ThemeToggle />
|
<QuickActions />
|
||||||
<LanguageSelector />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-sm md:min-w-sm min-w-xs">{children}</div>
|
<div className="max-w-sm md:min-w-sm min-w-xs">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { languages, SupportedLanguage } from "@/lib/i18n/locales";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "../ui/dropdown-menu";
|
||||||
|
import { useState } from "react";
|
||||||
|
import i18n from "@/lib/i18n/i18n";
|
||||||
|
import { useUserContext } from "@/context/user-context";
|
||||||
|
import { ScrollArea } from "../ui/scroll-area";
|
||||||
|
import { useTheme } from "../providers/theme-provider";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
DoorOpenIcon,
|
||||||
|
Languages,
|
||||||
|
Monitor,
|
||||||
|
Moon,
|
||||||
|
Palette,
|
||||||
|
Settings,
|
||||||
|
Sun,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useLocation } from "react-router";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import {
|
||||||
|
useScreenParams,
|
||||||
|
recompileScreenParams,
|
||||||
|
} from "@/lib/hooks/screen-params";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import axios from "axios";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
function Avatar({ initial }: { initial: string }) {
|
||||||
|
return (
|
||||||
|
<span className="group relative grid size-10 place-items-center rounded-full">
|
||||||
|
<span className="absolute inset-0 overflow-hidden rounded-full bg-linear-to-b from-neutral-50 to-neutral-100 dark:from-neutral-700 dark:to-neutral-950 shadow-lg"></span>
|
||||||
|
<span className="relative text-sm font-semibold text-primary">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QuickActions = () => {
|
||||||
|
const { auth } = useUserContext();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { search } = useLocation();
|
||||||
|
|
||||||
|
const [language, setLanguage] = useState<SupportedLanguage>(
|
||||||
|
i18n.language as SupportedLanguage,
|
||||||
|
);
|
||||||
|
|
||||||
|
const redirectTimer = useRef<number | null>(null);
|
||||||
|
const searchParams = new URLSearchParams(search);
|
||||||
|
const screenParams = useScreenParams(searchParams);
|
||||||
|
const compiledParams = recompileScreenParams(screenParams);
|
||||||
|
|
||||||
|
const logoutMutation = useMutation({
|
||||||
|
mutationFn: () => axios.post("/api/user/logout"),
|
||||||
|
mutationKey: ["logout"],
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("logoutSuccessTitle"), {
|
||||||
|
description: t("logoutSuccessSubtitle"),
|
||||||
|
});
|
||||||
|
|
||||||
|
redirectTimer.current = window.setTimeout(() => {
|
||||||
|
window.location.replace(`/login${compiledParams}`);
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("logoutFailTitle"), {
|
||||||
|
description: t("logoutFailSubtitle"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (redirectTimer.current) {
|
||||||
|
clearTimeout(redirectTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [redirectTimer]);
|
||||||
|
|
||||||
|
const initial = auth.authenticated
|
||||||
|
? (auth.name[0] || "U").toUpperCase()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleSelect = (option: string) => {
|
||||||
|
setLanguage(option as SupportedLanguage);
|
||||||
|
i18n.changeLanguage(option as SupportedLanguage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const themes = [
|
||||||
|
{ key: "light", label: t("quickActionsThemeLight"), icon: Sun },
|
||||||
|
{ key: "dark", label: t("quickActionsThemeDark"), icon: Moon },
|
||||||
|
{ key: "system", label: t("quickActionsThemeSystem"), icon: Monitor },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button className="rounded-full transition-transform duration-200 will-change-transform hover:scale-105 hover:cursor-pointer focus:ring-0 focus:outline-3 focus:outline-ring/50">
|
||||||
|
{auth.authenticated ? (
|
||||||
|
<Avatar initial={initial!} />
|
||||||
|
) : (
|
||||||
|
<span className="bg-card text-primary border-border size-10 flex items-center justify-center rounded-full border shadow-lg">
|
||||||
|
<Settings className="size-4" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
sideOffset={8}
|
||||||
|
className="rounded-xl p-1"
|
||||||
|
>
|
||||||
|
{auth.authenticated && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel className="flex items-center gap-3 p-2">
|
||||||
|
<div className="bg-foreground text-background flex size-9 shrink-0 items-center justify-center rounded-full text-sm font-medium">
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate text-sm font-medium">
|
||||||
|
{auth.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground truncate text-xs font-normal">
|
||||||
|
{auth.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>
|
||||||
|
<Languages className="size-4" />
|
||||||
|
{t("quickActionsLanguage")}
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuSubContent sideOffset={8} className="rounded-xl p-1">
|
||||||
|
<ScrollArea className="h-80">
|
||||||
|
{Object.entries(languages).map(([key, value]) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={key}
|
||||||
|
onSelect={() => handleSelect(key)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
{language === key && <Check className="size-4" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>
|
||||||
|
<Palette className="size-4" />
|
||||||
|
{t("quickActionsTheme")}
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuSubContent className="rounded-xl p-1" sideOffset={8}>
|
||||||
|
{themes.map(({ key, label, icon: Icon }) => (
|
||||||
|
<DropdownMenuItem key={key} onClick={() => setTheme(key)}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Icon className="size-4" />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{theme === key && <Check className="size-4" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
|
||||||
|
{auth.authenticated && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => logoutMutation.mutate()}
|
||||||
|
className="text-destructive"
|
||||||
|
>
|
||||||
|
<DoorOpenIcon className="size-4" />
|
||||||
|
{t("quickActionsLogout")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { Moon, Sun } from "lucide-react";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { useTheme } from "@/components/providers/theme-provider";
|
|
||||||
|
|
||||||
export function ThemeToggle() {
|
|
||||||
const { setTheme } = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
className="bg-card text-card-foreground hover:bg-card/90"
|
|
||||||
size="icon"
|
|
||||||
>
|
|
||||||
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
|
||||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
|
||||||
<span className="sr-only">Toggle theme</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
|
||||||
Light
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
|
||||||
Dark
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
|
||||||
System
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="relative flex-1 rounded-full bg-border"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
type UseLoginForProps = {
|
||||||
|
login_for?: "oidc" | "app";
|
||||||
|
compiledParams: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLoginFor = (props: UseLoginForProps): string => {
|
||||||
|
const { login_for, compiledParams } = props;
|
||||||
|
|
||||||
|
switch (login_for) {
|
||||||
|
case "oidc":
|
||||||
|
return "/oidc/authorize" + compiledParams;
|
||||||
|
case "app":
|
||||||
|
return "/continue" + compiledParams;
|
||||||
|
default:
|
||||||
|
return "/logout";
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -7,7 +7,7 @@ type IuseRedirectUri = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useRedirectUri = (
|
export const useRedirectUri = (
|
||||||
redirect_uri: string | null,
|
redirect_uri: string | undefined,
|
||||||
cookieDomain: string,
|
cookieDomain: string,
|
||||||
): IuseRedirectUri => {
|
): IuseRedirectUri => {
|
||||||
let isValid = false;
|
let isValid = false;
|
||||||
@@ -15,7 +15,7 @@ export const useRedirectUri = (
|
|||||||
let isAllowedProto = false;
|
let isAllowedProto = false;
|
||||||
let isHttpsDowngrade = false;
|
let isHttpsDowngrade = false;
|
||||||
|
|
||||||
if (!redirect_uri) {
|
if (redirect_uri === undefined) {
|
||||||
return {
|
return {
|
||||||
valid: isValid,
|
valid: isValid,
|
||||||
trusted: isTrusted,
|
trusted: isTrusted,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
type ScreenParams = {
|
type ScreenParams = {
|
||||||
login_for?: "oidc" | "app";
|
login_for?: "oidc" | "app";
|
||||||
redirect_url?: string;
|
redirect_uri?: string;
|
||||||
oidc_ticket?: string;
|
oidc_ticket?: string;
|
||||||
oidc_scope?: string;
|
oidc_scope?: string;
|
||||||
oidc_name?: string;
|
oidc_name?: string;
|
||||||
@@ -10,7 +10,7 @@ type ScreenParams = {
|
|||||||
|
|
||||||
const zodScreenParams = z.object({
|
const zodScreenParams = z.object({
|
||||||
login_for: z.enum(["oidc", "app"]).optional(),
|
login_for: z.enum(["oidc", "app"]).optional(),
|
||||||
redirect_url: z.string().optional(),
|
redirect_uri: z.string().optional(),
|
||||||
oidc_ticket: z.string().optional(),
|
oidc_ticket: z.string().optional(),
|
||||||
oidc_scope: z.string().optional(),
|
oidc_scope: z.string().optional(),
|
||||||
oidc_name: z.string().optional(),
|
oidc_name: z.string().optional(),
|
||||||
|
|||||||
@@ -92,5 +92,11 @@
|
|||||||
"loginTailscaleOtherMethod": "Login with another method",
|
"loginTailscaleOtherMethod": "Login with another method",
|
||||||
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
|
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
|
||||||
"loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.",
|
"loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.",
|
||||||
"logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout."
|
"logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout.",
|
||||||
|
"quickActionsLanguage": "Language",
|
||||||
|
"quickActionsTheme": "Theme",
|
||||||
|
"quickActionsThemeLight": "Light",
|
||||||
|
"quickActionsThemeDark": "Dark",
|
||||||
|
"quickActionsThemeSystem": "System",
|
||||||
|
"quickActionsLogout": "Logout"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,5 +92,11 @@
|
|||||||
"loginTailscaleOtherMethod": "Login with another method",
|
"loginTailscaleOtherMethod": "Login with another method",
|
||||||
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
|
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
|
||||||
"loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.",
|
"loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.",
|
||||||
"logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout."
|
"logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout.",
|
||||||
|
"quickActionsLanguage": "Language",
|
||||||
|
"quickActionsTheme": "Theme",
|
||||||
|
"quickActionsThemeLight": "Light",
|
||||||
|
"quickActionsThemeDark": "Dark",
|
||||||
|
"quickActionsThemeSystem": "System",
|
||||||
|
"quickActionsLogout": "Logout"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export const AuthorizePage = () => {
|
|||||||
{t("authorizeTitle")}
|
{t("authorizeTitle")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate("/")}
|
onClick={() => navigate(`/logout${compiledParams}`)}
|
||||||
disabled={authorizeMutation.isPending}
|
disabled={authorizeMutation.isPending}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { Trans, useTranslation } from "react-i18next";
|
|||||||
import { Navigate, useLocation, useNavigate } from "react-router";
|
import { Navigate, useLocation, useNavigate } from "react-router";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useRedirectUri } from "@/lib/hooks/redirect-uri";
|
import { useRedirectUri } from "@/lib/hooks/redirect-uri";
|
||||||
|
import {
|
||||||
|
recompileScreenParams,
|
||||||
|
useScreenParams,
|
||||||
|
} from "@/lib/hooks/screen-params";
|
||||||
|
|
||||||
export const ContinuePage = () => {
|
export const ContinuePage = () => {
|
||||||
const { app, ui } = useAppContext();
|
const { app, ui } = useAppContext();
|
||||||
@@ -25,7 +29,10 @@ export const ContinuePage = () => {
|
|||||||
const hasRedirected = useRef(false);
|
const hasRedirected = useRef(false);
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(search);
|
const searchParams = new URLSearchParams(search);
|
||||||
const redirectUri = searchParams.get("redirect_uri");
|
const screenParams = useScreenParams(searchParams);
|
||||||
|
const redirectUri = screenParams.redirect_uri;
|
||||||
|
const isAppLogin = screenParams.login_for === "app";
|
||||||
|
const recompiledParams = recompileScreenParams(screenParams);
|
||||||
|
|
||||||
const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri(
|
const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri(
|
||||||
redirectUri,
|
redirectUri,
|
||||||
@@ -43,7 +50,8 @@ export const ContinuePage = () => {
|
|||||||
auth.authenticated &&
|
auth.authenticated &&
|
||||||
hasValidRedirect &&
|
hasValidRedirect &&
|
||||||
!showUntrustedWarning &&
|
!showUntrustedWarning &&
|
||||||
!showInsecureWarning;
|
!showInsecureWarning &&
|
||||||
|
isAppLogin;
|
||||||
|
|
||||||
const redirectToTarget = useCallback(() => {
|
const redirectToTarget = useCallback(() => {
|
||||||
if (!urlHref || hasRedirected.current) {
|
if (!urlHref || hasRedirected.current) {
|
||||||
@@ -79,15 +87,10 @@ export const ContinuePage = () => {
|
|||||||
}, [shouldAutoRedirect, redirectToTarget]);
|
}, [shouldAutoRedirect, redirectToTarget]);
|
||||||
|
|
||||||
if (!auth.authenticated) {
|
if (!auth.authenticated) {
|
||||||
return (
|
return <Navigate to={`/login${recompiledParams}`} replace />;
|
||||||
<Navigate
|
|
||||||
to={`/login${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`}
|
|
||||||
replace
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasValidRedirect) {
|
if (!hasValidRedirect || !isAppLogin) {
|
||||||
return <Navigate to="/logout" replace />;
|
return <Navigate to="/logout" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,18 @@ import { useAppContext } from "@/context/app-context";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
|
import {
|
||||||
|
recompileScreenParams,
|
||||||
|
useScreenParams,
|
||||||
|
} from "@/lib/hooks/screen-params";
|
||||||
|
|
||||||
export const ForgotPasswordPage = () => {
|
export const ForgotPasswordPage = () => {
|
||||||
const { ui } = useAppContext();
|
const { ui } = useAppContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
const searchParams = new URLSearchParams(search);
|
const searchParams = new URLSearchParams(search);
|
||||||
|
const screenParams = useScreenParams(searchParams);
|
||||||
|
const compiledParams = recompileScreenParams(screenParams);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -37,10 +43,7 @@ export const ForgotPasswordPage = () => {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const eparams = searchParams.toString();
|
window.location.replace(`/login${compiledParams}`);
|
||||||
window.location.replace(
|
|
||||||
`/login${eparams.length > 0 ? `?${eparams}` : ""}`,
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("backToLoginButton")}
|
{t("backToLoginButton")}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
recompileScreenParams,
|
recompileScreenParams,
|
||||||
useScreenParams,
|
useScreenParams,
|
||||||
} from "@/lib/hooks/screen-params";
|
} from "@/lib/hooks/screen-params";
|
||||||
|
import { useLoginFor } from "@/lib/hooks/login-for";
|
||||||
|
|
||||||
const iconMap: Record<string, React.ReactNode> = {
|
const iconMap: Record<string, React.ReactNode> = {
|
||||||
google: <GoogleIcon />,
|
google: <GoogleIcon />,
|
||||||
@@ -62,12 +63,15 @@ export const LoginPage = () => {
|
|||||||
|
|
||||||
const searchParams = new URLSearchParams(search);
|
const searchParams = new URLSearchParams(search);
|
||||||
const screenParams = useScreenParams(searchParams);
|
const screenParams = useScreenParams(searchParams);
|
||||||
const isOidc = screenParams.login_for === "oidc";
|
|
||||||
const compiledParams = recompileScreenParams(screenParams);
|
const compiledParams = recompileScreenParams(screenParams);
|
||||||
|
const loginForUrl = useLoginFor({
|
||||||
|
login_for: screenParams.login_for,
|
||||||
|
compiledParams,
|
||||||
|
});
|
||||||
|
|
||||||
const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState(
|
const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState(
|
||||||
providers.find((provider) => provider.id === oauth.autoRedirect) !==
|
providers.find((provider) => provider.id === oauth.autoRedirect) !==
|
||||||
undefined && screenParams.redirect_url !== undefined,
|
undefined && screenParams.redirect_uri !== undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const oauthProviders = providers.filter(
|
const oauthProviders = providers.filter(
|
||||||
@@ -126,11 +130,7 @@ export const LoginPage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
redirectTimer.current = window.setTimeout(() => {
|
redirectTimer.current = window.setTimeout(() => {
|
||||||
if (screenParams.login_for === "oidc") {
|
window.location.replace(loginForUrl);
|
||||||
window.location.replace(`/oidc/authorize${compiledParams}`);
|
|
||||||
} else {
|
|
||||||
window.location.replace(`/continue${compiledParams}`);
|
|
||||||
}
|
|
||||||
}, 500);
|
}, 500);
|
||||||
},
|
},
|
||||||
onError: (error: AxiosError) => {
|
onError: (error: AxiosError) => {
|
||||||
@@ -153,7 +153,7 @@ export const LoginPage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
redirectTimer.current = window.setTimeout(() => {
|
redirectTimer.current = window.setTimeout(() => {
|
||||||
window.location.replace(`/continue${compiledParams}`);
|
window.location.replace(loginForUrl);
|
||||||
}, 500);
|
}, 500);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -168,7 +168,7 @@ export const LoginPage = () => {
|
|||||||
!auth.authenticated &&
|
!auth.authenticated &&
|
||||||
isOauthAutoRedirect &&
|
isOauthAutoRedirect &&
|
||||||
!hasAutoRedirectedRef.current &&
|
!hasAutoRedirectedRef.current &&
|
||||||
screenParams.redirect_url !== undefined
|
screenParams.login_for !== undefined
|
||||||
) {
|
) {
|
||||||
hasAutoRedirectedRef.current = true;
|
hasAutoRedirectedRef.current = true;
|
||||||
oauthMutate(oauth.autoRedirect);
|
oauthMutate(oauth.autoRedirect);
|
||||||
@@ -179,7 +179,7 @@ export const LoginPage = () => {
|
|||||||
hasAutoRedirectedRef,
|
hasAutoRedirectedRef,
|
||||||
oauth.autoRedirect,
|
oauth.autoRedirect,
|
||||||
isOauthAutoRedirect,
|
isOauthAutoRedirect,
|
||||||
screenParams.redirect_url,
|
screenParams.login_for,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -194,16 +194,8 @@ export const LoginPage = () => {
|
|||||||
};
|
};
|
||||||
}, [redirectTimer, redirectButtonTimer]);
|
}, [redirectTimer, redirectButtonTimer]);
|
||||||
|
|
||||||
if (auth.authenticated && isOidc) {
|
|
||||||
return <Navigate to={`/authorize${compiledParams}`} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auth.authenticated && screenParams.redirect_url !== undefined) {
|
|
||||||
return <Navigate to={`/continue${compiledParams}`} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auth.authenticated) {
|
if (auth.authenticated) {
|
||||||
return <Navigate to="/logout" replace />;
|
return <Navigate to={loginForUrl} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOauthAutoRedirect) {
|
if (isOauthAutoRedirect) {
|
||||||
|
|||||||
@@ -15,12 +15,21 @@ import { Navigate } from "react-router";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { type UseMutationResult } from "@tanstack/react-query";
|
import { type UseMutationResult } from "@tanstack/react-query";
|
||||||
import { type AxiosResponse } from "axios";
|
import { type AxiosResponse } from "axios";
|
||||||
|
import { useLocation } from "react-router";
|
||||||
|
import {
|
||||||
|
useScreenParams,
|
||||||
|
recompileScreenParams,
|
||||||
|
} from "@/lib/hooks/screen-params";
|
||||||
|
|
||||||
export const LogoutPage = () => {
|
export const LogoutPage = () => {
|
||||||
const { auth, oauth, tailscale } = useUserContext();
|
const { auth, oauth, tailscale } = useUserContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { search } = useLocation();
|
||||||
|
|
||||||
const redirectTimer = useRef<number | null>(null);
|
const redirectTimer = useRef<number | null>(null);
|
||||||
|
const searchParams = new URLSearchParams(search);
|
||||||
|
const screenParams = useScreenParams(searchParams);
|
||||||
|
const compiledParams = recompileScreenParams(screenParams);
|
||||||
|
|
||||||
const logoutMutation = useMutation({
|
const logoutMutation = useMutation({
|
||||||
mutationFn: () => axios.post("/api/user/logout"),
|
mutationFn: () => axios.post("/api/user/logout"),
|
||||||
@@ -31,7 +40,7 @@ export const LogoutPage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
redirectTimer.current = window.setTimeout(() => {
|
redirectTimer.current = window.setTimeout(() => {
|
||||||
window.location.replace("/login");
|
window.location.replace(`/login${compiledParams}`);
|
||||||
}, 500);
|
}, 500);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -50,7 +59,7 @@ export const LogoutPage = () => {
|
|||||||
}, [redirectTimer]);
|
}, [redirectTimer]);
|
||||||
|
|
||||||
if (!auth.authenticated) {
|
if (!auth.authenticated) {
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to={`/login${compiledParams}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oauth.active) {
|
if (oauth.active) {
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ import {
|
|||||||
recompileScreenParams,
|
recompileScreenParams,
|
||||||
useScreenParams,
|
useScreenParams,
|
||||||
} from "@/lib/hooks/screen-params";
|
} from "@/lib/hooks/screen-params";
|
||||||
|
import { useLoginFor } from "@/lib/hooks/login-for";
|
||||||
|
|
||||||
export const TotpPage = () => {
|
export const TotpPage = () => {
|
||||||
const { totp } = useUserContext();
|
const { totp, auth } = useUserContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
const formId = useId();
|
const formId = useId();
|
||||||
@@ -32,6 +33,10 @@ export const TotpPage = () => {
|
|||||||
const searchParams = new URLSearchParams(search);
|
const searchParams = new URLSearchParams(search);
|
||||||
const screenParams = useScreenParams(searchParams);
|
const screenParams = useScreenParams(searchParams);
|
||||||
const compiledParams = recompileScreenParams(screenParams);
|
const compiledParams = recompileScreenParams(screenParams);
|
||||||
|
const loginForUrl = useLoginFor({
|
||||||
|
login_for: screenParams.login_for,
|
||||||
|
compiledParams,
|
||||||
|
});
|
||||||
|
|
||||||
const totpMutation = useMutation({
|
const totpMutation = useMutation({
|
||||||
mutationFn: (values: TotpSchema) => axios.post("/api/user/totp", values),
|
mutationFn: (values: TotpSchema) => axios.post("/api/user/totp", values),
|
||||||
@@ -42,11 +47,7 @@ export const TotpPage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
redirectTimer.current = window.setTimeout(() => {
|
redirectTimer.current = window.setTimeout(() => {
|
||||||
if (screenParams.login_for === "oidc") {
|
window.location.replace(loginForUrl);
|
||||||
window.location.replace(`/oidc/authorize${compiledParams}`);
|
|
||||||
} else {
|
|
||||||
window.location.replace(`/continue${compiledParams}`);
|
|
||||||
}
|
|
||||||
}, 500);
|
}, 500);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -65,7 +66,10 @@ export const TotpPage = () => {
|
|||||||
}, [redirectTimer]);
|
}, [redirectTimer]);
|
||||||
|
|
||||||
if (!totp.pending) {
|
if (!totp.pending) {
|
||||||
return <Navigate to="/" replace />;
|
if (auth.authenticated) {
|
||||||
|
return <Navigate to={loginForUrl} replace />;
|
||||||
|
}
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
|
type FrontendLoginFor string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FrontendLoginForOIDC FrontendLoginFor = "oidc"
|
||||||
|
FrontendLoginForApp FrontendLoginFor = "app"
|
||||||
|
)
|
||||||
|
|
||||||
type UnauthorizedQuery struct {
|
type UnauthorizedQuery struct {
|
||||||
Username string `url:"username"`
|
Username string `url:"username"`
|
||||||
Resource string `url:"resource"`
|
Resource string `url:"resource"`
|
||||||
@@ -9,4 +16,5 @@ type UnauthorizedQuery struct {
|
|||||||
|
|
||||||
type RedirectQuery struct {
|
type RedirectQuery struct {
|
||||||
RedirectURI string `url:"redirect_uri"`
|
RedirectURI string `url:"redirect_uri"`
|
||||||
|
LoginFor FrontendLoginFor `url:"login_for"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
|||||||
if oauthPendingSession.CallbackParams.RedirectURI != "" {
|
if oauthPendingSession.CallbackParams.RedirectURI != "" {
|
||||||
queries, err := query.Values(RedirectQuery{
|
queries, err := query.Values(RedirectQuery{
|
||||||
RedirectURI: oauthPendingSession.CallbackParams.RedirectURI,
|
RedirectURI: oauthPendingSession.CallbackParams.RedirectURI,
|
||||||
|
LoginFor: FrontendLoginForApp,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -295,7 +296,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (controller *OAuthController) isOidcRequest(params service.OAuthCallbackParams) bool {
|
func (controller *OAuthController) isOidcRequest(params service.OAuthCallbackParams) bool {
|
||||||
return params.LoginFor == "oidc"
|
return params.LoginFor == string(FrontendLoginForOIDC)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (controller *OAuthController) getCookieDomain() string {
|
func (controller *OAuthController) getCookieDomain() string {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ type ClientCredentials struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizeScreenParams struct {
|
type AuthorizeScreenParams struct {
|
||||||
LoginFor string `url:"login_for"`
|
LoginFor FrontendLoginFor `url:"login_for"`
|
||||||
OIDCTicket string `url:"oidc_ticket"`
|
OIDCTicket string `url:"oidc_ticket"`
|
||||||
OIDCScope string `url:"oidc_scope"`
|
OIDCScope string `url:"oidc_scope"`
|
||||||
OIDCName string `url:"oidc_name"`
|
OIDCName string `url:"oidc_name"`
|
||||||
@@ -186,7 +186,7 @@ func (controller *OIDCController) authorize(c *gin.Context) {
|
|||||||
ticket := controller.oidc.CreateAuthorizeRequestTicket(req)
|
ticket := controller.oidc.CreateAuthorizeRequestTicket(req)
|
||||||
|
|
||||||
queries, err := query.Values(AuthorizeScreenParams{
|
queries, err := query.Values(AuthorizeScreenParams{
|
||||||
LoginFor: "oidc",
|
LoginFor: FrontendLoginForOIDC,
|
||||||
OIDCTicket: ticket,
|
OIDCTicket: ticket,
|
||||||
OIDCScope: req.Scope,
|
OIDCScope: req.Scope,
|
||||||
OIDCName: client.Name,
|
OIDCName: client.Name,
|
||||||
|
|||||||
@@ -275,6 +275,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
|||||||
|
|
||||||
queries, err := query.Values(RedirectQuery{
|
queries, err := query.Values(RedirectQuery{
|
||||||
RedirectURI: fmt.Sprintf("%s://%s%s", proxyCtx.Proto, proxyCtx.Host, proxyCtx.Path),
|
RedirectURI: fmt.Sprintf("%s://%s%s", proxyCtx.Proto, proxyCtx.Host, proxyCtx.Path),
|
||||||
|
LoginFor: FrontendLoginForApp,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user