mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-05-22 20:20:32 +00:00
feat: tailscale integration (#847)
This commit is contained in:
@@ -2,9 +2,9 @@ import { Navigate } from "react-router";
|
||||
import { useUserContext } from "./context/user-context";
|
||||
|
||||
export const App = () => {
|
||||
const { isLoggedIn } = useUserContext();
|
||||
const { auth } = useUserContext();
|
||||
|
||||
if (isLoggedIn) {
|
||||
if (auth.authenticated) {
|
||||
return <Navigate to="/logout" replace />;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,17 +6,17 @@ import { DomainWarning } from "../domain-warning/domain-warning";
|
||||
import { ThemeToggle } from "../theme-toggle/theme-toggle";
|
||||
|
||||
const BaseLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
const { backgroundImage, title } = useAppContext();
|
||||
const { ui } = useAppContext();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = title;
|
||||
}, [title]);
|
||||
document.title = ui.title;
|
||||
}, [ui.title]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col justify-center items-center min-h-svh px-4"
|
||||
style={{
|
||||
backgroundImage: `url(${backgroundImage})`,
|
||||
backgroundImage: `url(${ui.backgroundImage})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}}
|
||||
@@ -31,7 +31,7 @@ const BaseLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
};
|
||||
|
||||
export const Layout = () => {
|
||||
const { appUrl, warningsEnabled } = useAppContext();
|
||||
const { app, ui } = useAppContext();
|
||||
const [ignoreDomainWarning, setIgnoreDomainWarning] = useState(() => {
|
||||
return window.sessionStorage.getItem("ignoreDomainWarning") === "true";
|
||||
});
|
||||
@@ -42,11 +42,15 @@ export const Layout = () => {
|
||||
setIgnoreDomainWarning(true);
|
||||
}, [setIgnoreDomainWarning]);
|
||||
|
||||
if (!ignoreDomainWarning && warningsEnabled && appUrl !== currentUrl) {
|
||||
if (
|
||||
!ignoreDomainWarning &&
|
||||
ui.warningsEnabled &&
|
||||
!app.trustedDomains.includes(currentUrl)
|
||||
) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<DomainWarning
|
||||
appUrl={appUrl}
|
||||
appUrl={app.appUrl}
|
||||
currentUrl={currentUrl}
|
||||
onClick={() => handleIgnore()}
|
||||
/>
|
||||
|
||||
@@ -80,5 +80,17 @@
|
||||
"profileScopeDescription": "Allows the app to access your profile information.",
|
||||
"groupsScopeName": "Groups",
|
||||
"groupsScopeDescription": "Allows the app to access your group information.",
|
||||
"backToLoginButton": "Back to login"
|
||||
"backToLoginButton": "Back to login",
|
||||
"phoneScopeName": "Phone",
|
||||
"phoneScopeDescription": "Allows the app to access your phone number.",
|
||||
"addressScopeName": "Address",
|
||||
"addressScopeDescription": "Allows the app to access your address.",
|
||||
"loginTailscaleTitle": "Continue with Tailscale",
|
||||
"loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?",
|
||||
"loginTailscaleDeviceName": "Device name:",
|
||||
"loginTailscaleSubmit": "Continue with Tailscale",
|
||||
"loginTailscaleOtherMethod": "Login with another method",
|
||||
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -84,5 +84,13 @@
|
||||
"phoneScopeName": "Phone",
|
||||
"phoneScopeDescription": "Allows the app to access your phone number.",
|
||||
"addressScopeName": "Address",
|
||||
"addressScopeDescription": "Allows the app to access your address."
|
||||
"addressScopeDescription": "Allows the app to access your address.",
|
||||
"loginTailscaleTitle": "Continue with Tailscale",
|
||||
"loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?",
|
||||
"loginTailscaleDeviceName": "Device name:",
|
||||
"loginTailscaleSubmit": "Continue with Tailscale",
|
||||
"loginTailscaleOtherMethod": "Login with another method",
|
||||
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ const createScopeMap = (t: TFunction<"translation", undefined>): Scope[] => {
|
||||
};
|
||||
|
||||
export const AuthorizePage = () => {
|
||||
const { isLoggedIn } = useUserContext();
|
||||
const { auth } = useUserContext();
|
||||
const { search } = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -127,7 +127,7 @@ export const AuthorizePage = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
if (!auth.authenticated) {
|
||||
return <Navigate to={`/login?${oidcParams.compiled}`} replace />;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRedirectUri } from "@/lib/hooks/redirect-uri";
|
||||
|
||||
export const ContinuePage = () => {
|
||||
const { cookieDomain, warningsEnabled } = useAppContext();
|
||||
const { isLoggedIn } = useUserContext();
|
||||
const { app, ui } = useAppContext();
|
||||
const { auth } = useUserContext();
|
||||
const { search } = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -29,17 +29,18 @@ export const ContinuePage = () => {
|
||||
|
||||
const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri(
|
||||
redirectUri,
|
||||
cookieDomain,
|
||||
app.cookieDomain,
|
||||
);
|
||||
|
||||
const urlHref = url?.href;
|
||||
|
||||
const hasValidRedirect = valid && allowedProto;
|
||||
const showUntrustedWarning = hasValidRedirect && !trusted && warningsEnabled;
|
||||
const showUntrustedWarning =
|
||||
hasValidRedirect && !trusted && ui.warningsEnabled;
|
||||
const showInsecureWarning =
|
||||
hasValidRedirect && httpsDowngrade && warningsEnabled;
|
||||
hasValidRedirect && httpsDowngrade && ui.warningsEnabled;
|
||||
const shouldAutoRedirect =
|
||||
isLoggedIn &&
|
||||
auth.authenticated &&
|
||||
hasValidRedirect &&
|
||||
!showUntrustedWarning &&
|
||||
!showInsecureWarning;
|
||||
@@ -77,7 +78,7 @@ export const ContinuePage = () => {
|
||||
};
|
||||
}, [shouldAutoRedirect, redirectToTarget]);
|
||||
|
||||
if (!isLoggedIn) {
|
||||
if (!auth.authenticated) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/login${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`}
|
||||
@@ -104,7 +105,7 @@ export const ContinuePage = () => {
|
||||
components={{
|
||||
code: <code />,
|
||||
}}
|
||||
values={{ cookieDomain }}
|
||||
values={{ cookieDomain: app.cookieDomain }}
|
||||
shouldUnescape={true}
|
||||
/>
|
||||
</CardDescription>
|
||||
|
||||
@@ -13,7 +13,7 @@ import Markdown from "react-markdown";
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
export const ForgotPasswordPage = () => {
|
||||
const { forgotPasswordMessage } = useAppContext();
|
||||
const { ui } = useAppContext();
|
||||
const { t } = useTranslation();
|
||||
const { search } = useLocation();
|
||||
const searchParams = new URLSearchParams(search);
|
||||
@@ -26,8 +26,8 @@ export const ForgotPasswordPage = () => {
|
||||
<CardContent>
|
||||
<CardDescription>
|
||||
<Markdown>
|
||||
{forgotPasswordMessage !== ""
|
||||
? forgotPasswordMessage
|
||||
{ui.forgotPasswordMessage !== ""
|
||||
? ui.forgotPasswordMessage
|
||||
: t("forgotPasswordMessage")}
|
||||
</Markdown>
|
||||
</CardDescription>
|
||||
|
||||
@@ -36,12 +36,17 @@ const iconMap: Record<string, React.ReactNode> = {
|
||||
};
|
||||
|
||||
export const LoginPage = () => {
|
||||
const { isLoggedIn } = useUserContext();
|
||||
const { providers, title, oauthAutoRedirect } = useAppContext();
|
||||
const { auth, tailscale } = useUserContext();
|
||||
const {
|
||||
ui,
|
||||
oauth,
|
||||
auth: { providers },
|
||||
} = useAppContext();
|
||||
const { search } = useLocation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [showRedirectButton, setShowRedirectButton] = useState(false);
|
||||
const [useTailscale, setUseTailscale] = useState(tailscale.nodeName !== "");
|
||||
|
||||
const hasAutoRedirectedRef = useRef(false);
|
||||
|
||||
@@ -55,7 +60,7 @@ export const LoginPage = () => {
|
||||
const oidcParams = useOIDCParams(searchParams);
|
||||
|
||||
const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState(
|
||||
providers.find((provider) => provider.id === oauthAutoRedirect) !==
|
||||
providers.find((provider) => provider.id === oauth.autoRedirect) !==
|
||||
undefined && redirectUri !== undefined,
|
||||
);
|
||||
|
||||
@@ -148,21 +153,47 @@ export const LoginPage = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: tailscaleMutate, isPending: tailscaleIsPending } =
|
||||
useMutation({
|
||||
mutationFn: () => axios.post("/api/user/tailscale"),
|
||||
mutationKey: ["tailscale"],
|
||||
onSuccess: () => {
|
||||
toast.success(t("loginSuccessTitle"), {
|
||||
description: t("loginTailscaleSuccess"),
|
||||
});
|
||||
|
||||
redirectTimer.current = window.setTimeout(() => {
|
||||
if (oidcParams.isOidc) {
|
||||
window.location.replace(`/authorize?${oidcParams.compiled}`);
|
||||
return;
|
||||
}
|
||||
window.location.replace(
|
||||
`/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`,
|
||||
);
|
||||
}, 500);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("loginFailTitle"), {
|
||||
description: t("loginTailscaleFail"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isLoggedIn &&
|
||||
!auth.authenticated &&
|
||||
isOauthAutoRedirect &&
|
||||
!hasAutoRedirectedRef.current &&
|
||||
redirectUri !== undefined
|
||||
) {
|
||||
hasAutoRedirectedRef.current = true;
|
||||
oauthMutate(oauthAutoRedirect);
|
||||
oauthMutate(oauth.autoRedirect);
|
||||
}
|
||||
}, [
|
||||
isLoggedIn,
|
||||
auth.authenticated,
|
||||
oauthMutate,
|
||||
hasAutoRedirectedRef,
|
||||
oauthAutoRedirect,
|
||||
oauth.autoRedirect,
|
||||
isOauthAutoRedirect,
|
||||
redirectUri,
|
||||
]);
|
||||
@@ -179,11 +210,11 @@ export const LoginPage = () => {
|
||||
};
|
||||
}, [redirectTimer, redirectButtonTimer]);
|
||||
|
||||
if (isLoggedIn && oidcParams.isOidc) {
|
||||
if (auth.authenticated && oidcParams.isOidc) {
|
||||
return <Navigate to={`/authorize?${oidcParams.compiled}`} replace />;
|
||||
}
|
||||
|
||||
if (isLoggedIn && redirectUri !== undefined) {
|
||||
if (auth.authenticated && redirectUri !== undefined) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`}
|
||||
@@ -192,7 +223,7 @@ export const LoginPage = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoggedIn) {
|
||||
if (auth.authenticated) {
|
||||
return <Navigate to="/logout" replace />;
|
||||
}
|
||||
|
||||
@@ -228,10 +259,49 @@ export const LoginPage = () => {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (useTailscale) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="gap-3">
|
||||
<TailscaleIcon className="mx-auto h-8 w-8" />
|
||||
<CardTitle className="text-center text-xl">
|
||||
{t("loginTailscaleTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t("loginTailscaleDescription")}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t("loginTailscaleDeviceName")} <code>{tailscale.nodeName}</code>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-stretch gap-3">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => tailscaleMutate()}
|
||||
loading={tailscaleIsPending}
|
||||
>
|
||||
{t("loginTailscaleSubmit")}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
onClick={() => setUseTailscale(false)}
|
||||
disabled={tailscaleIsPending}
|
||||
>
|
||||
{t("loginTailscaleOtherMethod")}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="gap-1.5">
|
||||
<CardTitle className="text-center text-xl">{title}</CardTitle>
|
||||
<CardTitle className="text-center text-xl">{ui.title}</CardTitle>
|
||||
{providers.length > 0 && (
|
||||
<CardDescription className="text-center">
|
||||
{oauthProviders.length !== 0
|
||||
|
||||
@@ -13,9 +13,11 @@ import { useEffect, useRef } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Navigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { type UseMutationResult } from "@tanstack/react-query";
|
||||
import { type AxiosResponse } from "axios";
|
||||
|
||||
export const LogoutPage = () => {
|
||||
const { provider, username, isLoggedIn, email, oauthName } = useUserContext();
|
||||
const { auth, oauth, tailscale } = useUserContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const redirectTimer = useRef<number | null>(null);
|
||||
@@ -47,42 +49,82 @@ export const LogoutPage = () => {
|
||||
};
|
||||
}, [redirectTimer]);
|
||||
|
||||
if (!isLoggedIn) {
|
||||
if (!auth.authenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (oauth.active) {
|
||||
return (
|
||||
<LogoutLayout logoutMutation={logoutMutation}>
|
||||
<Trans
|
||||
i18nKey="logoutOauthSubtitle"
|
||||
t={t}
|
||||
components={{
|
||||
code: <code />,
|
||||
}}
|
||||
values={{
|
||||
username: auth.email,
|
||||
provider: oauth.displayName,
|
||||
}}
|
||||
shouldUnescape={true}
|
||||
/>
|
||||
</LogoutLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (auth.providerId === "tailscale") {
|
||||
return (
|
||||
<LogoutLayout logoutMutation={logoutMutation}>
|
||||
<Trans
|
||||
i18nKey="logoutTailscaleSubtitle"
|
||||
t={t}
|
||||
components={{
|
||||
code: <code />,
|
||||
}}
|
||||
values={{
|
||||
deviceName: tailscale.nodeName,
|
||||
}}
|
||||
shouldUnescape={true}
|
||||
/>
|
||||
</LogoutLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LogoutLayout logoutMutation={logoutMutation}>
|
||||
<Trans
|
||||
i18nKey="logoutUsernameSubtitle"
|
||||
t={t}
|
||||
components={{
|
||||
code: <code />,
|
||||
}}
|
||||
values={{
|
||||
username: auth.username,
|
||||
}}
|
||||
shouldUnescape={true}
|
||||
/>
|
||||
</LogoutLayout>
|
||||
);
|
||||
};
|
||||
|
||||
interface LogoutLayoutProps {
|
||||
children: React.ReactNode;
|
||||
logoutMutation: UseMutationResult<
|
||||
//eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-empty-object-type
|
||||
AxiosResponse<any, any, {}>,
|
||||
Error,
|
||||
void,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
function LogoutLayout({ children, logoutMutation }: LogoutLayoutProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="gap-1.5">
|
||||
<CardTitle className="text-xl">{t("logoutTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{provider !== "local" && provider !== "ldap" ? (
|
||||
<Trans
|
||||
i18nKey="logoutOauthSubtitle"
|
||||
t={t}
|
||||
components={{
|
||||
code: <code />,
|
||||
}}
|
||||
values={{
|
||||
username: email,
|
||||
provider: oauthName,
|
||||
}}
|
||||
shouldUnescape={true}
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="logoutUsernameSubtitle"
|
||||
t={t}
|
||||
components={{
|
||||
code: <code />,
|
||||
}}
|
||||
values={{
|
||||
username,
|
||||
}}
|
||||
shouldUnescape={true}
|
||||
/>
|
||||
)}
|
||||
</CardDescription>
|
||||
<CardDescription>{children}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Button
|
||||
@@ -96,4 +138,4 @@ export const LogoutPage = () => {
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { toast } from "sonner";
|
||||
import { useOIDCParams } from "@/lib/hooks/oidc";
|
||||
|
||||
export const TotpPage = () => {
|
||||
const { totpPending } = useUserContext();
|
||||
const { totp } = useUserContext();
|
||||
const { t } = useTranslation();
|
||||
const { search } = useLocation();
|
||||
const formId = useId();
|
||||
@@ -64,7 +64,7 @@ export const TotpPage = () => {
|
||||
};
|
||||
}, [redirectTimer]);
|
||||
|
||||
if (!totpPending) {
|
||||
if (!totp.pending) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,15 +6,32 @@ export const providerSchema = z.object({
|
||||
oauth: z.boolean(),
|
||||
});
|
||||
|
||||
export const appContextSchema = z.object({
|
||||
const authSchema = z.object({
|
||||
providers: z.array(providerSchema),
|
||||
});
|
||||
|
||||
const oauthSchema = z.object({
|
||||
autoRedirect: z.string(),
|
||||
});
|
||||
|
||||
const uiSchema = z.object({
|
||||
title: z.string(),
|
||||
appUrl: z.string(),
|
||||
cookieDomain: z.string(),
|
||||
forgotPasswordMessage: z.string(),
|
||||
backgroundImage: z.string(),
|
||||
oauthAutoRedirect: z.string(),
|
||||
warningsEnabled: z.boolean(),
|
||||
});
|
||||
|
||||
const appSchema = z.object({
|
||||
appUrl: z.string(),
|
||||
cookieDomain: z.string(),
|
||||
trustedDomains: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const appContextSchema = z.object({
|
||||
auth: authSchema,
|
||||
oauth: oauthSchema,
|
||||
ui: uiSchema,
|
||||
app: appSchema,
|
||||
});
|
||||
|
||||
export type AppContextSchema = z.infer<typeof appContextSchema>;
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const userContextSchema = z.object({
|
||||
isLoggedIn: z.boolean(),
|
||||
const authSchema = z.object({
|
||||
authenticated: z.boolean(),
|
||||
username: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
provider: z.string(),
|
||||
oauth: z.boolean(),
|
||||
totpPending: z.boolean(),
|
||||
oauthName: z.string(),
|
||||
providerId: z.string(),
|
||||
});
|
||||
|
||||
const oauthSchema = z.object({
|
||||
active: z.boolean(),
|
||||
displayName: z.string(),
|
||||
});
|
||||
|
||||
const totpSchema = z.object({
|
||||
pending: z.boolean(),
|
||||
});
|
||||
|
||||
const tailscaleSchema = z.object({
|
||||
nodeName: z.string(),
|
||||
});
|
||||
|
||||
export const userContextSchema = z.object({
|
||||
auth: authSchema,
|
||||
oauth: oauthSchema,
|
||||
totp: totpSchema,
|
||||
tailscale: tailscaleSchema,
|
||||
});
|
||||
|
||||
export type UserContextSchema = z.infer<typeof userContextSchema>;
|
||||
|
||||
Reference in New Issue
Block a user