diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0559b26f..f258eb98 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ; } diff --git a/frontend/src/components/layout/layout.tsx b/frontend/src/components/layout/layout.tsx index a71a1aa9..d59aadf3 100644 --- a/frontend/src/components/layout/layout.tsx +++ b/frontend/src/components/layout/layout.tsx @@ -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 (
{ }; 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 ( handleIgnore()} /> diff --git a/frontend/src/lib/i18n/locales/en-US.json b/frontend/src/lib/i18n/locales/en-US.json index 0a5a76c8..a71696e2 100644 --- a/frontend/src/lib/i18n/locales/en-US.json +++ b/frontend/src/lib/i18n/locales/en-US.json @@ -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 {{deviceName}}. Click the button below to logout." } diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index dd39a6ce..a71696e2 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -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 {{deviceName}}. Click the button below to logout." } diff --git a/frontend/src/pages/authorize-page.tsx b/frontend/src/pages/authorize-page.tsx index f2b7c11d..91f8f9c9 100644 --- a/frontend/src/pages/authorize-page.tsx +++ b/frontend/src/pages/authorize-page.tsx @@ -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 ; } diff --git a/frontend/src/pages/continue-page.tsx b/frontend/src/pages/continue-page.tsx index b7cdd743..82846c64 100644 --- a/frontend/src/pages/continue-page.tsx +++ b/frontend/src/pages/continue-page.tsx @@ -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 ( { components={{ code: , }} - values={{ cookieDomain }} + values={{ cookieDomain: app.cookieDomain }} shouldUnescape={true} /> diff --git a/frontend/src/pages/forgot-password-page.tsx b/frontend/src/pages/forgot-password-page.tsx index 7d47d02f..6438e353 100644 --- a/frontend/src/pages/forgot-password-page.tsx +++ b/frontend/src/pages/forgot-password-page.tsx @@ -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 = () => { - {forgotPasswordMessage !== "" - ? forgotPasswordMessage + {ui.forgotPasswordMessage !== "" + ? ui.forgotPasswordMessage : t("forgotPasswordMessage")} diff --git a/frontend/src/pages/login-page.tsx b/frontend/src/pages/login-page.tsx index c39a0fb6..26538cf3 100644 --- a/frontend/src/pages/login-page.tsx +++ b/frontend/src/pages/login-page.tsx @@ -36,12 +36,17 @@ const iconMap: Record = { }; 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 ; } - if (isLoggedIn && redirectUri !== undefined) { + if (auth.authenticated && redirectUri !== undefined) { return ( { ); } - if (isLoggedIn) { + if (auth.authenticated) { return ; } @@ -228,10 +259,49 @@ export const LoginPage = () => { ); } + + if (useTailscale) { + return ( + + + + + {t("loginTailscaleTitle")} + + + +
+ {t("loginTailscaleDescription")} +
+
+ {t("loginTailscaleDeviceName")} {tailscale.nodeName} +
+
+ + + + +
+ ); + } + return ( - {title} + {ui.title} {providers.length > 0 && ( {oauthProviders.length !== 0 diff --git a/frontend/src/pages/logout-page.tsx b/frontend/src/pages/logout-page.tsx index f60bd656..bd1704c0 100644 --- a/frontend/src/pages/logout-page.tsx +++ b/frontend/src/pages/logout-page.tsx @@ -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(null); @@ -47,42 +49,82 @@ export const LogoutPage = () => { }; }, [redirectTimer]); - if (!isLoggedIn) { + if (!auth.authenticated) { return ; } + if (oauth.active) { + return ( + + , + }} + values={{ + username: auth.email, + provider: oauth.displayName, + }} + shouldUnescape={true} + /> + + ); + } + + if (auth.providerId === "tailscale") { + return ( + + , + }} + values={{ + deviceName: tailscale.nodeName, + }} + shouldUnescape={true} + /> + + ); + } + + return ( + + , + }} + values={{ + username: auth.username, + }} + shouldUnescape={true} + /> + + ); +}; + +interface LogoutLayoutProps { + children: React.ReactNode; + logoutMutation: UseMutationResult< + //eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-empty-object-type + AxiosResponse, + Error, + void, + unknown + >; +} + +function LogoutLayout({ children, logoutMutation }: LogoutLayoutProps) { + const { t } = useTranslation(); return ( {t("logoutTitle")} - - {provider !== "local" && provider !== "ldap" ? ( - , - }} - values={{ - username: email, - provider: oauthName, - }} - shouldUnescape={true} - /> - ) : ( - , - }} - values={{ - username, - }} - shouldUnescape={true} - /> - )} - + {children}