Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions frontend/src/components/icons/local-auth.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { SVGProps } from "react";

export function LocalAuthIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
{...props}
>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0-8 0M6 21v-2a4 4 0 0 1 4-4h5m3.5 3.5L15 22l-1.5-1.5m5.054-2.086a2 2 0 1 1 2.828-2.828a2 2 0 0 1-2.828 2.828M16 19l1 1"
></path>
</svg>
);
}
108 changes: 87 additions & 21 deletions frontend/src/components/quick-actions/quick-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
Palette,
Settings,
Sun,
UserRoundKey,
X,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
Expand All @@ -37,20 +39,26 @@ import { useMutation } from "@tanstack/react-query";
import axios from "axios";
import { toast } from "sonner";
import { useEffect } from "react";
import { GoogleIcon } from "../icons/google";
import { GithubIcon } from "../icons/github";
import { TailscaleIcon } from "../icons/tailscale";
import { MicrosoftIcon } from "../icons/microsoft";
import { PocketIDIcon } from "../icons/pocket-id";
import { OAuthIcon } from "../icons/oauth";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";

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>
);
}
const iconStyles = "size-4";

const iconMap: Record<string, React.ReactNode> = {
google: <GoogleIcon className={iconStyles} />,
github: <GithubIcon className={iconStyles} />,
tailscale: <TailscaleIcon className={iconStyles} />,
microsoft: <MicrosoftIcon className={iconStyles} />,
pocketid: <PocketIDIcon className={iconStyles} />,
};

export const QuickActions = () => {
const { auth } = useUserContext();
const { auth, oauth, tailscale } = useUserContext();
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const { search } = useLocation();
Expand All @@ -64,6 +72,49 @@ export const QuickActions = () => {
const screenParams = useScreenParams(searchParams);
const compiledParams = recompileScreenParams(screenParams);

const [isOpen, setIsOpen] = useState(false);

const providerDetails = (():
| { name: string; icon: React.ReactNode }
| undefined => {
if (!auth.authenticated) {
return undefined;
}

if (auth.providerId === "local" || auth.providerId === "ldap") {
return {
name: t(
auth.providerId === "ldap"
? "quickActionsProviderLDAP"
: "quickActionsProviderLocal",
),
icon: (
<UserRoundKey
strokeWidth={1.5}
size={16}
className="text-muted-foreground ml-0.5"
/>
),
};
}

if (oauth.active) {
return {
name: t("quickActionsProviderOAuth", { provider: oauth.displayName }),
icon: iconMap[auth.providerId] || <OAuthIcon className={iconStyles} />,
};
}

if (auth.providerId === "tailscale") {
return {
name: `Tailscale (${tailscale.nodeName})`,
icon: <TailscaleIcon className={iconStyles} />,
};
}

return undefined;
})();

const logoutMutation = useMutation({
mutationFn: () => axios.post("/api/user/logout"),
mutationKey: ["logout"],
Expand Down Expand Up @@ -107,17 +158,29 @@ export const QuickActions = () => {
] as const;

return (
<DropdownMenu>
<DropdownMenu onOpenChange={(open) => setIsOpen(open)} open={isOpen}>
<DropdownMenuTrigger asChild>
<button
aria-label={t("quickActionsTitle")}
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!} />
<div className="size-10 flex justify-center items-center p-2 rounded-full bg-card border border-border">
{isOpen ? (
<X className="size-4 text-primary rotate-0 transition-transform duration-200 starting:rotate-45" />
) : (
<span className="text-sm text-primary rotate-0 transition-transform duration-200 starting:-rotate-45">
{initial}
</span>
)}
</div>
) : (
<span className="bg-card text-primary border-border size-10 flex items-center justify-center rounded-full border shadow-lg">
<Settings className="size-4" />
<Settings
className={`size-4 transition-transform duration-200 ${
isOpen ? "rotate-45" : "rotate-0"
}`}
/>
</span>
)}
</button>
Expand All @@ -126,19 +189,22 @@ export const QuickActions = () => {
<DropdownMenuContent
align="end"
sideOffset={8}
className="rounded-xl p-1"
className="rounded-xl p-1 w-3xs"
>
{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">
<Tooltip>
<TooltipTrigger className="size-9 rounded-full p-2 bg-muted border-border border flex items-center justify-center">
{providerDetails!.icon}
</TooltipTrigger>
<TooltipContent>{providerDetails!.name}</TooltipContent>
</Tooltip>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-medium">
{auth.name}
</span>
<span className="text-muted-foreground truncate text-xs font-normal">
<span className="text-muted-foreground truncate text-xs">
{auth.email}
</span>
</div>
Expand Down Expand Up @@ -197,7 +263,7 @@ export const QuickActions = () => {
onSelect={() => logoutMutation.mutate()}
className="text-destructive"
>
<DoorOpenIcon className="size-4" />
<DoorOpenIcon className="size-4 text-destructive" />
{t("quickActionsLogout")}
</DropdownMenuItem>
</>
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/lib/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,5 +99,8 @@
"quickActionsThemeDark": "Dark",
"quickActionsThemeSystem": "System",
"quickActionsLogout": "Logout",
"quickActionsTitle": "Quick Actions"
"quickActionsTitle": "Quick Actions",
"quickActionsProviderLocal": "Local",
"quickActionsProviderLDAP": "LDAP",
"quickActionsProviderOAuth": "{{provider}} OAuth"
}
5 changes: 4 additions & 1 deletion frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,5 +99,8 @@
"quickActionsThemeDark": "Dark",
"quickActionsThemeSystem": "System",
"quickActionsLogout": "Logout",
"quickActionsTitle": "Quick Actions"
"quickActionsTitle": "Quick Actions",
"quickActionsProviderLocal": "Local",
"quickActionsProviderLDAP": "LDAP",
"quickActionsProviderOAuth": "{{provider}} OAuth"
}
2 changes: 1 addition & 1 deletion frontend/src/pages/logout-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ function LogoutLayout({ children, logoutMutation }: LogoutLayoutProps) {
</CardHeader>
<CardFooter>
<Button
className="w-full"
className="w-full text-destructive"
variant="outline"
loading={logoutMutation.isPending}
onClick={() => logoutMutation.mutate()}
Expand Down
5 changes: 5 additions & 0 deletions internal/bootstrap/app_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/url"
"os"
"os/signal"
"slices"
"sort"
"strings"
"syscall"
Expand Down Expand Up @@ -131,6 +132,10 @@ func (app *BootstrapApp) Setup() error {
app.runtime.OAuthProviders = app.config.OAuth.Providers

for id, provider := range app.runtime.OAuthProviders {
if slices.Contains(model.ReservedProviderNames, id) {
return fmt.Errorf("provider id %s is reserved and cannot be used", id)
}

providerWhitelist, err := utils.GetStringList(provider.Whitelist, provider.WhitelistFile)
if err != nil {
return fmt.Errorf("failed to load oauth whitelist for provider %s: %w", id, err)
Expand Down
2 changes: 2 additions & 0 deletions internal/model/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ var OverrideProviders = map[string]string{
"github": "GitHub",
}

var ReservedProviderNames = []string{"local", "ldap", "tailscale"}

const SessionCookieName = "tinyauth-session"
const CSRFCookieName = "tinyauth-csrf"
const RedirectCookieName = "tinyauth-redirect"
Expand Down