From 4093a91e12e1cabefca76f29acaa043c80f6cd82 Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 31 Aug 2025 01:11:18 +0300 Subject: [PATCH 1/8] wip --- cmd/root.go | 1 - .../domain-warning/domain-warning.tsx | 36 +++++++++ frontend/src/components/layout/layout.tsx | 16 +++- frontend/src/components/ui/button.tsx | 2 +- frontend/src/lib/i18n/locales/en-US.json | 5 +- frontend/src/lib/i18n/locales/en.json | 5 +- frontend/src/pages/continue-page.tsx | 80 ++++++++++++------- frontend/src/schemas/app-context-schema.ts | 4 +- internal/bootstrap/app_bootstrap.go | 4 +- internal/config/config.go | 1 - internal/controller/context_controller.go | 15 ++-- 11 files changed, 116 insertions(+), 53 deletions(-) create mode 100644 frontend/src/components/domain-warning/domain-warning.tsx diff --git a/cmd/root.go b/cmd/root.go index 3ae72925..171e0433 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -95,7 +95,6 @@ func init() { {"generic-user-url", "", "Generic OAuth user info URL."}, {"generic-name", "Generic", "Generic OAuth provider name."}, {"generic-skip-ssl", false, "Skip SSL verification for the generic OAuth provider."}, - {"disable-continue", false, "Disable continue screen and redirect to app directly."}, {"oauth-whitelist", "", "Comma separated list of email addresses to whitelist when using OAuth."}, {"oauth-auto-redirect", "none", "Auto redirect to the specified OAuth provider if configured. (available providers: github, google, generic)"}, {"session-expiry", 86400, "Session (cookie) expiration time in seconds."}, diff --git a/frontend/src/components/domain-warning/domain-warning.tsx b/frontend/src/components/domain-warning/domain-warning.tsx new file mode 100644 index 00000000..10c836e0 --- /dev/null +++ b/frontend/src/components/domain-warning/domain-warning.tsx @@ -0,0 +1,36 @@ +import { + Card, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "../ui/card"; +import { Button } from "../ui/button"; + +interface Props { + onClick: () => void; + appUrl: string; + currentUrl: string; +} + +export const DomainWarning = (props: Props) => { + const { onClick, appUrl, currentUrl } = props; + + return ( + + + Incorrect Domain + + This instance is configured to be accessed from {appUrl}, + but {currentUrl} is being used. Authentication will most + likely fail if you proceed. + + + + + + + ); +}; diff --git a/frontend/src/components/layout/layout.tsx b/frontend/src/components/layout/layout.tsx index 773185b0..e14e015d 100644 --- a/frontend/src/components/layout/layout.tsx +++ b/frontend/src/components/layout/layout.tsx @@ -1,9 +1,13 @@ import { useAppContext } from "@/context/app-context"; import { LanguageSelector } from "../language/language"; import { Outlet } from "react-router"; +import { useState } from "react"; +import { DomainWarning } from "../domain-warning/domain-warning"; export const Layout = () => { - const { backgroundImage } = useAppContext(); + const { backgroundImage, appUrl } = useAppContext(); + const [ignoreDomainWarning, setIgnoreDomainWarning] = useState(false); + const currentUrl = window.location.origin; return (
{ }} > - + {appUrl !== currentUrl && !ignoreDomainWarning ? ( + setIgnoreDomainWarning(true)} + appUrl={appUrl} + currentUrl={currentUrl} + /> + ) : ( + + )}
); }; diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index fbb5b27a..4badcc10 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -22,7 +22,7 @@ const buttonVariants = cva( "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", warning: - "bg-amber-500 text-white shadow-xs hover:bg-amber-400 focus-visible:ring-amber-200/20 dark:focus-visible:ring-amber-400/40 dark:bg-amber-600", + "bg-amber-500 text-white shadow-xs hover:bg-amber-400 focus-visible:ring-amber-200/20 dark:focus-visible:ring-amber-400/40", }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", diff --git a/frontend/src/lib/i18n/locales/en-US.json b/frontend/src/lib/i18n/locales/en-US.json index 74e422f5..a90d5818 100644 --- a/frontend/src/lib/i18n/locales/en-US.json +++ b/frontend/src/lib/i18n/locales/en-US.json @@ -20,8 +20,7 @@ "continueInvalidRedirectSubtitle": "The redirect URL is invalid", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", + "continueRedirectManually": "Redirect me manually", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Please try again", "logoutSuccessTitle": "Logged out", @@ -45,7 +44,7 @@ "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", + "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{rootDomain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 74e422f5..a90d5818 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -20,8 +20,7 @@ "continueInvalidRedirectSubtitle": "The redirect URL is invalid", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", + "continueRedirectManually": "Redirect me manually", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Please try again", "logoutSuccessTitle": "Logged out", @@ -45,7 +44,7 @@ "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", + "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{rootDomain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", diff --git a/frontend/src/pages/continue-page.tsx b/frontend/src/pages/continue-page.tsx index cc4d4326..f97752fc 100644 --- a/frontend/src/pages/continue-page.tsx +++ b/frontend/src/pages/continue-page.tsx @@ -12,7 +12,7 @@ import { isValidUrl } from "@/lib/utils"; import { Trans, useTranslation } from "react-i18next"; import { Navigate, useLocation, useNavigate } from "react-router"; import DOMPurify from "dompurify"; -import { useState } from "react"; +import { useEffect, useState } from "react"; export const ContinuePage = () => { const { isLoggedIn } = useUserContext(); @@ -21,9 +21,10 @@ export const ContinuePage = () => { return ; } - const { domain, disableContinue } = useAppContext(); + const { rootDomain } = useAppContext(); const { search } = useLocation(); const [loading, setLoading] = useState(false); + const [showRedirectButton, setShowRedirectButton] = useState(false); const searchParams = new URLSearchParams(search); const redirectURI = searchParams.get("redirect_uri"); @@ -36,21 +37,20 @@ export const ContinuePage = () => { return ; } + const { t } = useTranslation(); + const navigate = useNavigate(); + const handleRedirect = () => { setLoading(true); window.location.href = DOMPurify.sanitize(redirectURI); - } - - if (disableContinue) { - handleRedirect(); - } + }; - const { t } = useTranslation(); - const navigate = useNavigate(); + const redirectURLObj = new URL(redirectURI); - const url = new URL(redirectURI); - - if (!(url.hostname == domain) && !url.hostname.endsWith(`.${domain}`)) { + if ( + !(redirectURLObj.hostname == rootDomain) && + !redirectURLObj.hostname.endsWith(`.${rootDomain}`) + ) { return ( @@ -64,7 +64,7 @@ export const ContinuePage = () => { components={{ code: , }} - values={{ domain }} + values={{ rootDomain }} /> @@ -76,7 +76,11 @@ export const ContinuePage = () => { > {t("continueTitle")} - @@ -84,7 +88,10 @@ export const ContinuePage = () => { ); } - if (url.protocol === "http:" && window.location.protocol === "https:") { + if ( + redirectURLObj.protocol === "http:" && + window.location.protocol === "https:" + ) { return ( @@ -102,14 +109,14 @@ export const ContinuePage = () => { - - @@ -117,20 +124,31 @@ export const ContinuePage = () => { ); } + useEffect(() => { + setTimeout(() => { + handleRedirect(); + }, 100); + setTimeout(() => { + setLoading(false); + setShowRedirectButton(true); + }, 1000); + }, []); + return ( - {t("continueTitle")} - {t("continueSubtitle")} + + {t("continueRedirectingTitle")} + + {t("continueRedirectingSubtitle")} - - - + {showRedirectButton && ( + + + + )} ); }; diff --git a/frontend/src/schemas/app-context-schema.ts b/frontend/src/schemas/app-context-schema.ts index 31ded496..c5d6d85f 100644 --- a/frontend/src/schemas/app-context-schema.ts +++ b/frontend/src/schemas/app-context-schema.ts @@ -2,10 +2,10 @@ import { z } from "zod"; export const appContextSchema = z.object({ configuredProviders: z.array(z.string()), - disableContinue: z.boolean(), title: z.string(), genericName: z.string(), - domain: z.string(), + appUrl: z.string(), + rootDomain: z.string(), forgotPasswordMessage: z.string(), oauthAutoRedirect: z.enum(["none", "github", "google", "generic"]), backgroundImage: z.string(), diff --git a/internal/bootstrap/app_bootstrap.go b/internal/bootstrap/app_bootstrap.go index af75aa6d..e941772b 100644 --- a/internal/bootstrap/app_bootstrap.go +++ b/internal/bootstrap/app_bootstrap.go @@ -177,10 +177,10 @@ func (app *BootstrapApp) Setup() error { // Create controllers contextController := controller.NewContextController(controller.ContextControllerConfig{ ConfiguredProviders: configuredProviders, - DisableContinue: app.Config.DisableContinue, Title: app.Config.Title, GenericName: app.Config.GenericName, - Domain: domain, + AppURL: app.Config.AppURL, + RootDomain: domain, ForgotPasswordMessage: app.Config.ForgotPasswordMessage, BackgroundImage: app.Config.BackgroundImage, OAuthAutoRedirect: app.Config.OAuthAutoRedirect, diff --git a/internal/config/config.go b/internal/config/config.go index c959e265..82050def 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,7 +36,6 @@ type Config struct { GenericUserURL string `mapstructure:"generic-user-url"` GenericName string `mapstructure:"generic-name"` GenericSkipSSL bool `mapstructure:"generic-skip-ssl"` - DisableContinue bool `mapstructure:"disable-continue"` OAuthWhitelist string `mapstructure:"oauth-whitelist"` OAuthAutoRedirect string `mapstructure:"oauth-auto-redirect" validate:"oneof=none github google generic"` SessionExpiry int `mapstructure:"session-expiry"` diff --git a/internal/controller/context_controller.go b/internal/controller/context_controller.go index c7570f0e..f2bd068e 100644 --- a/internal/controller/context_controller.go +++ b/internal/controller/context_controller.go @@ -15,7 +15,7 @@ type UserContextResponse struct { Name string `json:"name"` Email string `json:"email"` Provider string `json:"provider"` - Oauth bool `json:"oauth"` + OAuth bool `json:"oauth"` TotpPending bool `json:"totpPending"` } @@ -23,10 +23,10 @@ type AppContextResponse struct { Status int `json:"status"` Message string `json:"message"` ConfiguredProviders []string `json:"configuredProviders"` - DisableContinue bool `json:"disableContinue"` Title string `json:"title"` GenericName string `json:"genericName"` - Domain string `json:"domain"` + AppURL string `json:"appUrl"` + RootDomain string `json:"rootDomain"` ForgotPasswordMessage string `json:"forgotPasswordMessage"` BackgroundImage string `json:"backgroundImage"` OAuthAutoRedirect string `json:"oauthAutoRedirect"` @@ -37,7 +37,8 @@ type ContextControllerConfig struct { DisableContinue bool Title string GenericName string - Domain string + AppURL string + RootDomain string ForgotPasswordMessage string BackgroundImage string OAuthAutoRedirect string @@ -72,7 +73,7 @@ func (controller *ContextController) userContextHandler(c *gin.Context) { Name: context.Name, Email: context.Email, Provider: context.Provider, - Oauth: context.OAuth, + OAuth: context.OAuth, TotpPending: context.TotpPending, } @@ -93,10 +94,10 @@ func (controller *ContextController) appContextHandler(c *gin.Context) { Status: 200, Message: "Success", ConfiguredProviders: controller.Config.ConfiguredProviders, - DisableContinue: controller.Config.DisableContinue, Title: controller.Config.Title, GenericName: controller.Config.GenericName, - Domain: controller.Config.Domain, + AppURL: controller.Config.AppURL, + RootDomain: controller.Config.RootDomain, ForgotPasswordMessage: controller.Config.ForgotPasswordMessage, BackgroundImage: controller.Config.BackgroundImage, OAuthAutoRedirect: controller.Config.OAuthAutoRedirect, From d9fda75d412a3ed30d671e99d2826cfdfe36d9f3 Mon Sep 17 00:00:00 2001 From: Stavros Date: Mon, 1 Sep 2025 14:43:39 +0300 Subject: [PATCH 2/8] refactor: update domain warning layout --- .../domain-warning/domain-warning.tsx | 2 +- frontend/src/components/layout/layout.tsx | 44 ++++++++++++++----- frontend/src/index.css | 2 +- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/domain-warning/domain-warning.tsx b/frontend/src/components/domain-warning/domain-warning.tsx index 10c836e0..dfa061dc 100644 --- a/frontend/src/components/domain-warning/domain-warning.tsx +++ b/frontend/src/components/domain-warning/domain-warning.tsx @@ -28,7 +28,7 @@ export const DomainWarning = (props: Props) => { diff --git a/frontend/src/components/layout/layout.tsx b/frontend/src/components/layout/layout.tsx index e14e015d..0ed0b39b 100644 --- a/frontend/src/components/layout/layout.tsx +++ b/frontend/src/components/layout/layout.tsx @@ -4,10 +4,8 @@ import { Outlet } from "react-router"; import { useState } from "react"; import { DomainWarning } from "../domain-warning/domain-warning"; -export const Layout = () => { - const { backgroundImage, appUrl } = useAppContext(); - const [ignoreDomainWarning, setIgnoreDomainWarning] = useState(false); - const currentUrl = window.location.origin; +const BaseLayout = ({ children }: { children: React.ReactNode }) => { + const { backgroundImage } = useAppContext(); return (
{ }} > - {appUrl !== currentUrl && !ignoreDomainWarning ? ( + {children} +
+ ); +}; + +export const Layout = () => { + const { appUrl } = useAppContext(); + const [ignoreDomainWarning, setIgnoreDomainWarning] = useState(false); + const currentUrl = window.location.origin; + const sessionIgnore = window.sessionStorage.getItem("ignoreDomainWarning"); + + const handleIgnore = () => { + window.sessionStorage.setItem("ignoreDomainWarning", "true"); + setIgnoreDomainWarning(true); + }; + + if ( + !ignoreDomainWarning && + appUrl !== currentUrl && + sessionIgnore !== "true" + ) { + return ( + setIgnoreDomainWarning(true)} appUrl={appUrl} currentUrl={currentUrl} + onClick={() => handleIgnore()} /> - ) : ( - - )} - + + ); + } + + return ( + + + ); }; diff --git a/frontend/src/index.css b/frontend/src/index.css index 0b1ee02c..97016361 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -156,7 +156,7 @@ ul { } code { - @apply relative rounded bg-muted px-[0.2rem] py-[0.1rem] font-mono text-sm font-semibold; + @apply relative rounded bg-muted px-[0.2rem] py-[0.1rem] font-mono text-sm font-semibold break-all; } .lead { From 3873bb279c0ab994d36466492dbf037680df3ac8 Mon Sep 17 00:00:00 2001 From: Stavros Date: Mon, 1 Sep 2025 15:46:50 +0300 Subject: [PATCH 3/8] i18n: add domain warning translations --- .../components/domain-warning/domain-warning.tsx | 15 ++++++++++----- frontend/src/lib/i18n/locales/en-US.json | 13 +++++++------ frontend/src/lib/i18n/locales/en.json | 13 +++++++------ frontend/src/pages/continue-page.tsx | 4 ++-- 4 files changed, 26 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/domain-warning/domain-warning.tsx b/frontend/src/components/domain-warning/domain-warning.tsx index dfa061dc..975a3ab3 100644 --- a/frontend/src/components/domain-warning/domain-warning.tsx +++ b/frontend/src/components/domain-warning/domain-warning.tsx @@ -6,6 +6,7 @@ import { CardTitle, } from "../ui/card"; import { Button } from "../ui/button"; +import { Trans, useTranslation } from "react-i18next"; interface Props { onClick: () => void; @@ -15,20 +16,24 @@ interface Props { export const DomainWarning = (props: Props) => { const { onClick, appUrl, currentUrl } = props; + const { t } = useTranslation(); return ( - Incorrect Domain + {t("domainWarningTitle")} - This instance is configured to be accessed from {appUrl}, - but {currentUrl} is being used. Authentication will most - likely fail if you proceed. + }} + /> diff --git a/frontend/src/lib/i18n/locales/en-US.json b/frontend/src/lib/i18n/locales/en-US.json index a90d5818..3480e6c7 100644 --- a/frontend/src/lib/i18n/locales/en-US.json +++ b/frontend/src/lib/i18n/locales/en-US.json @@ -16,11 +16,11 @@ "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", "continueRedirectingTitle": "Redirecting...", "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueRedirectManually": "Redirect me manually", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{rootDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Please try again", "logoutSuccessTitle": "Logged out", @@ -43,8 +43,6 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{rootDomain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -52,5 +50,8 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index a90d5818..3480e6c7 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -16,11 +16,11 @@ "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", "continueRedirectingTitle": "Redirecting...", "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueRedirectManually": "Redirect me manually", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{rootDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Please try again", "logoutSuccessTitle": "Logged out", @@ -43,8 +43,6 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{rootDomain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -52,5 +50,8 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore" } \ No newline at end of file diff --git a/frontend/src/pages/continue-page.tsx b/frontend/src/pages/continue-page.tsx index f97752fc..0038a1d2 100644 --- a/frontend/src/pages/continue-page.tsx +++ b/frontend/src/pages/continue-page.tsx @@ -55,11 +55,11 @@ export const ContinuePage = () => { - {t("untrustedRedirectTitle")} + {t("continueUntrustedRedirectTitle")} , From 4ef5eef167df6395cdcb4ff6d8ec92b604f8e21a Mon Sep 17 00:00:00 2001 From: Stavros Date: Mon, 1 Sep 2025 16:41:12 +0300 Subject: [PATCH 4/8] refactor: rework hooks usage --- frontend/src/components/layout/layout.tsx | 17 ++--- frontend/src/lib/i18n/locales/en-US.json | 1 + frontend/src/lib/i18n/locales/en.json | 1 + frontend/src/pages/continue-page.tsx | 87 +++++++++++++---------- frontend/src/pages/login-page.tsx | 30 +++++--- frontend/src/pages/logout-page.tsx | 11 ++- frontend/src/pages/totp-page.tsx | 9 ++- frontend/src/pages/unauthorized-page.tsx | 16 ++--- 8 files changed, 96 insertions(+), 76 deletions(-) diff --git a/frontend/src/components/layout/layout.tsx b/frontend/src/components/layout/layout.tsx index 0ed0b39b..3461000b 100644 --- a/frontend/src/components/layout/layout.tsx +++ b/frontend/src/components/layout/layout.tsx @@ -1,7 +1,7 @@ import { useAppContext } from "@/context/app-context"; import { LanguageSelector } from "../language/language"; import { Outlet } from "react-router"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { DomainWarning } from "../domain-warning/domain-warning"; const BaseLayout = ({ children }: { children: React.ReactNode }) => { @@ -24,20 +24,17 @@ const BaseLayout = ({ children }: { children: React.ReactNode }) => { export const Layout = () => { const { appUrl } = useAppContext(); - const [ignoreDomainWarning, setIgnoreDomainWarning] = useState(false); + const [ignoreDomainWarning, setIgnoreDomainWarning] = useState(() => { + return window.sessionStorage.getItem("ignoreDomainWarning") === "true"; + }); const currentUrl = window.location.origin; - const sessionIgnore = window.sessionStorage.getItem("ignoreDomainWarning"); - const handleIgnore = () => { + const handleIgnore = useCallback(() => { window.sessionStorage.setItem("ignoreDomainWarning", "true"); setIgnoreDomainWarning(true); - }; + }, [setIgnoreDomainWarning]); - if ( - !ignoreDomainWarning && - appUrl !== currentUrl && - sessionIgnore !== "true" - ) { + if (!ignoreDomainWarning && appUrl !== currentUrl) { return ( { - const { isLoggedIn } = useUserContext(); - - if (!isLoggedIn) { - return ; - } - const { rootDomain } = useAppContext(); + const { isLoggedIn } = useUserContext(); const { search } = useLocation(); + const { t } = useTranslation(); + const navigate = useNavigate(); + const [loading, setLoading] = useState(false); const [showRedirectButton, setShowRedirectButton] = useState(false); const searchParams = new URLSearchParams(search); - const redirectURI = searchParams.get("redirect_uri"); + const redirectUri = searchParams.get("redirect_uri"); - if (!redirectURI) { - return ; - } - - if (!isValidUrl(DOMPurify.sanitize(redirectURI))) { - return ; - } - - const { t } = useTranslation(); - const navigate = useNavigate(); + const isValidRedirectUri = + redirectUri !== null ? isValidUrl(DOMPurify.sanitize(redirectUri)) : false; + const redirectUriObj = isValidRedirectUri + ? new URL(redirectUri as string) + : null; + const isTrustedRedirectUri = + redirectUriObj !== null + ? redirectUriObj.hostname === rootDomain || + redirectUriObj.hostname.endsWith(`.${rootDomain}`) + : false; + const isHttpsDowngrade = + redirectUriObj !== null + ? redirectUriObj.protocol === "http:" && + window.location.protocol === "https:" + : false; const handleRedirect = () => { setLoading(true); - window.location.href = DOMPurify.sanitize(redirectURI); + window.location.replace(DOMPurify.sanitize(redirectUriObj!.toString())); }; - const redirectURLObj = new URL(redirectURI); + useEffect(() => { + if ( + !isLoggedIn || + !isValidRedirectUri || + !isTrustedRedirectUri || + isHttpsDowngrade + ) { + return; + } + + setTimeout(() => { + handleRedirect(); + }, 100); + + setTimeout(() => { + setLoading(false); + setShowRedirectButton(true); + }, 1000); + }, []); + + if (!isLoggedIn) { + return ; + } - if ( - !(redirectURLObj.hostname == rootDomain) && - !redirectURLObj.hostname.endsWith(`.${rootDomain}`) - ) { + if (!isValidRedirectUri) { + return ; + } + + if (!isTrustedRedirectUri) { return ( @@ -88,10 +114,7 @@ export const ContinuePage = () => { ); } - if ( - redirectURLObj.protocol === "http:" && - window.location.protocol === "https:" - ) { + if (isHttpsDowngrade) { return ( @@ -124,16 +147,6 @@ export const ContinuePage = () => { ); } - useEffect(() => { - setTimeout(() => { - handleRedirect(); - }, 100); - setTimeout(() => { - setLoading(false); - setShowRedirectButton(true); - }, 1000); - }, []); - return ( diff --git a/frontend/src/pages/login-page.tsx b/frontend/src/pages/login-page.tsx index 53f183f1..64d8b3ca 100644 --- a/frontend/src/pages/login-page.tsx +++ b/frontend/src/pages/login-page.tsx @@ -24,12 +24,8 @@ import { toast } from "sonner"; export const LoginPage = () => { const { isLoggedIn } = useUserContext(); - - if (isLoggedIn) { - return ; - } - - const { configuredProviders, title, oauthAutoRedirect, genericName } = useAppContext(); + const { configuredProviders, title, oauthAutoRedirect, genericName } = + useAppContext(); const { search } = useLocation(); const { t } = useTranslation(); const isMounted = useIsMounted(); @@ -54,7 +50,7 @@ export const LoginPage = () => { }); setTimeout(() => { - window.location.href = data.data.url; + window.location.replace(data.data.url); }, 500); }, onError: () => { @@ -100,6 +96,7 @@ export const LoginPage = () => { if ( oauthConfigured && configuredProviders.includes(oauthAutoRedirect) && + !isLoggedIn && redirectUri ) { oauthMutation.mutate(oauthAutoRedirect); @@ -107,6 +104,10 @@ export const LoginPage = () => { } }, []); + if (isLoggedIn) { + return ; + } + return ( @@ -126,7 +127,10 @@ export const LoginPage = () => { icon={} className="w-full" onClick={() => oauthMutation.mutate("google")} - loading={oauthMutation.isPending && oauthMutation.variables === "google"} + loading={ + oauthMutation.isPending && + oauthMutation.variables === "google" + } disabled={oauthMutation.isPending || loginMutation.isPending} /> )} @@ -136,7 +140,10 @@ export const LoginPage = () => { icon={} className="w-full" onClick={() => oauthMutation.mutate("github")} - loading={oauthMutation.isPending && oauthMutation.variables === "github"} + loading={ + oauthMutation.isPending && + oauthMutation.variables === "github" + } disabled={oauthMutation.isPending || loginMutation.isPending} /> )} @@ -146,7 +153,10 @@ export const LoginPage = () => { icon={} className="w-full" onClick={() => oauthMutation.mutate("generic")} - loading={oauthMutation.isPending && oauthMutation.variables === "generic"} + loading={ + oauthMutation.isPending && + oauthMutation.variables === "generic" + } disabled={oauthMutation.isPending || loginMutation.isPending} /> )} diff --git a/frontend/src/pages/logout-page.tsx b/frontend/src/pages/logout-page.tsx index 30b2af8c..e453db77 100644 --- a/frontend/src/pages/logout-page.tsx +++ b/frontend/src/pages/logout-page.tsx @@ -17,11 +17,6 @@ import { toast } from "sonner"; export const LogoutPage = () => { const { provider, username, isLoggedIn, email } = useUserContext(); - - if (!isLoggedIn) { - return ; - } - const { genericName } = useAppContext(); const { t } = useTranslation(); @@ -33,7 +28,7 @@ export const LogoutPage = () => { description: t("logoutSuccessSubtitle"), }); - setTimeout(async () => { + setTimeout(() => { window.location.replace("/login"); }, 500); }, @@ -44,6 +39,10 @@ export const LogoutPage = () => { }, }); + if (!isLoggedIn) { + return ; + } + return ( diff --git a/frontend/src/pages/totp-page.tsx b/frontend/src/pages/totp-page.tsx index 7d4ebad1..8a07e6dd 100644 --- a/frontend/src/pages/totp-page.tsx +++ b/frontend/src/pages/totp-page.tsx @@ -19,11 +19,6 @@ import { toast } from "sonner"; export const TotpPage = () => { const { totpPending } = useUserContext(); - - if (!totpPending) { - return ; - } - const { t } = useTranslation(); const { search } = useLocation(); const formId = useId(); @@ -52,6 +47,10 @@ export const TotpPage = () => { }, }); + if (!totpPending) { + return ; + } + return ( diff --git a/frontend/src/pages/unauthorized-page.tsx b/frontend/src/pages/unauthorized-page.tsx index e0bd6cac..007e01c5 100644 --- a/frontend/src/pages/unauthorized-page.tsx +++ b/frontend/src/pages/unauthorized-page.tsx @@ -12,6 +12,10 @@ import { Navigate, useLocation, useNavigate } from "react-router"; export const UnauthorizedPage = () => { const { search } = useLocation(); + const { t } = useTranslation(); + const navigate = useNavigate(); + + const [loading, setLoading] = useState(false); const searchParams = new URLSearchParams(search); const username = searchParams.get("username"); @@ -19,19 +23,15 @@ export const UnauthorizedPage = () => { const groupErr = searchParams.get("groupErr"); const ip = searchParams.get("ip"); - if (!username && !ip) { - return ; - } - - const { t } = useTranslation(); - const navigate = useNavigate(); - const [loading, setLoading] = useState(false); - const handleRedirect = () => { setLoading(true); navigate("/login"); }; + if (!username && !ip) { + return ; + } + let i18nKey = "unauthorizedLoginSubtitle"; if (resource) { From 57dd8a6d81233fd565d60178c216a74b9f956617 Mon Sep 17 00:00:00 2001 From: Stavros Date: Mon, 1 Sep 2025 17:04:33 +0300 Subject: [PATCH 5/8] feat: clear timeouts --- frontend/bun.lock | 5 ----- frontend/package.json | 1 - frontend/src/pages/continue-page.tsx | 22 ++++++++++++++++------ frontend/src/pages/login-page.tsx | 8 ++++++-- frontend/src/pages/logout-page.tsx | 4 +++- frontend/src/pages/totp-page.tsx | 4 +++- 6 files changed, 28 insertions(+), 16 deletions(-) diff --git a/frontend/bun.lock b/frontend/bun.lock index 12b197ba..1f98f9c4 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -14,7 +14,6 @@ "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "dompurify": "^3.2.6", "i18next": "^25.4.2", "i18next-browser-languagedetector": "^8.2.0", "i18next-resources-to-backend": "^1.2.1", @@ -364,8 +363,6 @@ "@types/react-dom": ["@types/react-dom@19.1.8", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-xG7xaBMJCpcK0RpN8jDbAACQo54ycO6h4dSSmgv8+fu6ZIAdANkx/WsawASUjVXYfy+J9AbUpRMNNEsXCDfDBQ=="], - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], - "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.41.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.41.0", "@typescript-eslint/type-utils": "8.41.0", "@typescript-eslint/utils": "8.41.0", "@typescript-eslint/visitor-keys": "8.41.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.41.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-8fz6oa6wEKZrhXWro/S3n2eRJqlRcIa6SlDh59FXJ5Wp5XRZ8B9ixpJDcjadHq47hMx0u+HW6SNa6LjJQ6NLtw=="], @@ -476,8 +473,6 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - "dompurify": ["dompurify@3.2.6", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ=="], - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "electron-to-chromium": ["electron-to-chromium@1.5.151", "", {}, "sha512-Rl6uugut2l9sLojjS4H4SAr3A4IgACMLgpuEMPYCVcKydzfyPrn5absNRju38IhQOf/NwjJY8OGWjlteqYeBCA=="], diff --git a/frontend/package.json b/frontend/package.json index 2161e05e..3d3fc47d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,6 @@ "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "dompurify": "^3.2.6", "i18next": "^25.4.2", "i18next-browser-languagedetector": "^8.2.0", "i18next-resources-to-backend": "^1.2.1", diff --git a/frontend/src/pages/continue-page.tsx b/frontend/src/pages/continue-page.tsx index 9cc85c26..4bb43b45 100644 --- a/frontend/src/pages/continue-page.tsx +++ b/frontend/src/pages/continue-page.tsx @@ -11,7 +11,6 @@ import { useUserContext } from "@/context/user-context"; import { isValidUrl } from "@/lib/utils"; import { Trans, useTranslation } from "react-i18next"; import { Navigate, useLocation, useNavigate } from "react-router"; -import DOMPurify from "dompurify"; import { useEffect, useState } from "react"; export const ContinuePage = () => { @@ -28,7 +27,7 @@ export const ContinuePage = () => { const redirectUri = searchParams.get("redirect_uri"); const isValidRedirectUri = - redirectUri !== null ? isValidUrl(DOMPurify.sanitize(redirectUri)) : false; + redirectUri !== null ? isValidUrl(redirectUri) : false; const redirectUriObj = isValidRedirectUri ? new URL(redirectUri as string) : null; @@ -37,6 +36,11 @@ export const ContinuePage = () => { ? redirectUriObj.hostname === rootDomain || redirectUriObj.hostname.endsWith(`.${rootDomain}`) : false; + const isAllowedRedirectProto = + redirectUriObj !== null + ? redirectUriObj.protocol === "https:" || + redirectUriObj.protocol === "http:" + : false; const isHttpsDowngrade = redirectUriObj !== null ? redirectUriObj.protocol === "http:" && @@ -45,7 +49,7 @@ export const ContinuePage = () => { const handleRedirect = () => { setLoading(true); - window.location.replace(DOMPurify.sanitize(redirectUriObj!.toString())); + window.location.replace(redirectUriObj!.toString()); }; useEffect(() => { @@ -53,26 +57,32 @@ export const ContinuePage = () => { !isLoggedIn || !isValidRedirectUri || !isTrustedRedirectUri || + !isAllowedRedirectProto || isHttpsDowngrade ) { return; } - setTimeout(() => { + const auto = setTimeout(() => { handleRedirect(); }, 100); - setTimeout(() => { + const reveal = setTimeout(() => { setLoading(false); setShowRedirectButton(true); }, 1000); + + return () => { + clearTimeout(auto); + clearTimeout(reveal); + }; }, []); if (!isLoggedIn) { return ; } - if (!isValidRedirectUri) { + if (!isValidRedirectUri || !isAllowedRedirectProto) { return ; } diff --git a/frontend/src/pages/login-page.tsx b/frontend/src/pages/login-page.tsx index 64d8b3ca..9ce7be7e 100644 --- a/frontend/src/pages/login-page.tsx +++ b/frontend/src/pages/login-page.tsx @@ -49,9 +49,11 @@ export const LoginPage = () => { description: t("loginOauthSuccessSubtitle"), }); - setTimeout(() => { + const redirect = setTimeout(() => { window.location.replace(data.data.url); }, 500); + + return () => clearTimeout(redirect); }, onError: () => { toast.error(t("loginOauthFailTitle"), { @@ -75,11 +77,13 @@ export const LoginPage = () => { description: t("loginSuccessSubtitle"), }); - setTimeout(() => { + const redirect = setTimeout(() => { window.location.replace( `/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`, ); }, 500); + + return () => clearTimeout(redirect); }, onError: (error: AxiosError) => { toast.error(t("loginFailTitle"), { diff --git a/frontend/src/pages/logout-page.tsx b/frontend/src/pages/logout-page.tsx index e453db77..1229a35a 100644 --- a/frontend/src/pages/logout-page.tsx +++ b/frontend/src/pages/logout-page.tsx @@ -28,9 +28,11 @@ export const LogoutPage = () => { description: t("logoutSuccessSubtitle"), }); - setTimeout(() => { + const redirect = setTimeout(() => { window.location.replace("/login"); }, 500); + + return () => clearTimeout(redirect); }, onError: () => { toast.error(t("logoutFailTitle"), { diff --git a/frontend/src/pages/totp-page.tsx b/frontend/src/pages/totp-page.tsx index 8a07e6dd..4e0bdb13 100644 --- a/frontend/src/pages/totp-page.tsx +++ b/frontend/src/pages/totp-page.tsx @@ -34,11 +34,13 @@ export const TotpPage = () => { description: t("totpSuccessSubtitle"), }); - setTimeout(() => { + const redirect = setTimeout(() => { window.location.replace( `/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`, ); }, 500); + + return () => clearTimeout(redirect); }, onError: () => { toast.error(t("totpFailTitle"), { From 0c18a64466439c598d7b039e4ddb152143f21c1a Mon Sep 17 00:00:00 2001 From: Stavros Date: Mon, 1 Sep 2025 17:29:23 +0300 Subject: [PATCH 6/8] fix: use useeffect to cleanup timeout --- frontend/src/pages/login-page.tsx | 19 ++++++++++++------- frontend/src/pages/logout-page.tsx | 14 +++++++++++--- frontend/src/pages/totp-page.tsx | 15 +++++++++++---- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/frontend/src/pages/login-page.tsx b/frontend/src/pages/login-page.tsx index 9ce7be7e..8fae53f3 100644 --- a/frontend/src/pages/login-page.tsx +++ b/frontend/src/pages/login-page.tsx @@ -17,7 +17,7 @@ import { useIsMounted } from "@/lib/hooks/use-is-mounted"; import { LoginSchema } from "@/schemas/login-schema"; import { useMutation } from "@tanstack/react-query"; import axios, { AxiosError } from "axios"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Navigate, useLocation } from "react-router"; import { toast } from "sonner"; @@ -30,6 +30,8 @@ export const LoginPage = () => { const { t } = useTranslation(); const isMounted = useIsMounted(); + const redirectTimer = useRef(null); + const searchParams = new URLSearchParams(search); const redirectUri = searchParams.get("redirect_uri"); @@ -49,11 +51,9 @@ export const LoginPage = () => { description: t("loginOauthSuccessSubtitle"), }); - const redirect = setTimeout(() => { + redirectTimer.current = window.setTimeout(() => { window.location.replace(data.data.url); }, 500); - - return () => clearTimeout(redirect); }, onError: () => { toast.error(t("loginOauthFailTitle"), { @@ -77,13 +77,11 @@ export const LoginPage = () => { description: t("loginSuccessSubtitle"), }); - const redirect = setTimeout(() => { + redirectTimer.current = window.setTimeout(() => { window.location.replace( `/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`, ); }, 500); - - return () => clearTimeout(redirect); }, onError: (error: AxiosError) => { toast.error(t("loginFailTitle"), { @@ -108,6 +106,13 @@ export const LoginPage = () => { } }, []); + useEffect( + () => () => { + if (redirectTimer.current) clearTimeout(redirectTimer.current); + }, + [], + ); + if (isLoggedIn) { return ; } diff --git a/frontend/src/pages/logout-page.tsx b/frontend/src/pages/logout-page.tsx index 1229a35a..6b52ed2b 100644 --- a/frontend/src/pages/logout-page.tsx +++ b/frontend/src/pages/logout-page.tsx @@ -11,6 +11,7 @@ import { useUserContext } from "@/context/user-context"; import { capitalize } from "@/lib/utils"; import { useMutation } from "@tanstack/react-query"; import axios from "axios"; +import { useEffect, useRef } from "react"; import { Trans, useTranslation } from "react-i18next"; import { Navigate } from "react-router"; import { toast } from "sonner"; @@ -20,6 +21,8 @@ export const LogoutPage = () => { const { genericName } = useAppContext(); const { t } = useTranslation(); + const redirectTimer = useRef(null); + const logoutMutation = useMutation({ mutationFn: () => axios.post("/api/user/logout"), mutationKey: ["logout"], @@ -28,11 +31,9 @@ export const LogoutPage = () => { description: t("logoutSuccessSubtitle"), }); - const redirect = setTimeout(() => { + redirectTimer.current = window.setTimeout(() => { window.location.replace("/login"); }, 500); - - return () => clearTimeout(redirect); }, onError: () => { toast.error(t("logoutFailTitle"), { @@ -41,6 +42,13 @@ export const LogoutPage = () => { }, }); + useEffect( + () => () => { + if (redirectTimer.current) clearTimeout(redirectTimer.current); + }, + [], + ); + if (!isLoggedIn) { return ; } diff --git a/frontend/src/pages/totp-page.tsx b/frontend/src/pages/totp-page.tsx index 4e0bdb13..3306b52c 100644 --- a/frontend/src/pages/totp-page.tsx +++ b/frontend/src/pages/totp-page.tsx @@ -12,7 +12,7 @@ import { useUserContext } from "@/context/user-context"; import { TotpSchema } from "@/schemas/totp-schema"; import { useMutation } from "@tanstack/react-query"; import axios from "axios"; -import { useId } from "react"; +import { useEffect, useId, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Navigate, useLocation } from "react-router"; import { toast } from "sonner"; @@ -23,6 +23,8 @@ export const TotpPage = () => { const { search } = useLocation(); const formId = useId(); + const redirectTimer = useRef(null); + const searchParams = new URLSearchParams(search); const redirectUri = searchParams.get("redirect_uri"); @@ -34,13 +36,11 @@ export const TotpPage = () => { description: t("totpSuccessSubtitle"), }); - const redirect = setTimeout(() => { + redirectTimer.current = window.setTimeout(() => { window.location.replace( `/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`, ); }, 500); - - return () => clearTimeout(redirect); }, onError: () => { toast.error(t("totpFailTitle"), { @@ -49,6 +49,13 @@ export const TotpPage = () => { }, }); + useEffect( + () => () => { + if (redirectTimer.current) clearTimeout(redirectTimer.current); + }, + [], + ); + if (!totpPending) { return ; } From c80c37ba6951022d766d24787b9ba4da899ca517 Mon Sep 17 00:00:00 2001 From: Stavros Date: Mon, 1 Sep 2025 18:01:45 +0300 Subject: [PATCH 7/8] refactor: rework redirects and history storage --- frontend/src/App.tsx | 4 ++-- .../domain-warning/domain-warning.tsx | 19 +++++++++++++++++-- frontend/src/lib/i18n/locales/en-US.json | 3 ++- frontend/src/lib/i18n/locales/en.json | 3 ++- frontend/src/pages/continue-page.tsx | 15 ++++++++++----- frontend/src/pages/login-page.tsx | 11 ++++++++++- frontend/src/pages/logout-page.tsx | 4 ++-- frontend/src/pages/totp-page.tsx | 2 +- internal/controller/context_controller.go | 7 +++++-- 9 files changed, 51 insertions(+), 17 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 72b9238b..0559b26f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,8 +5,8 @@ export const App = () => { const { isLoggedIn } = useUserContext(); if (isLoggedIn) { - return ; + return ; } - return ; + return ; }; diff --git a/frontend/src/components/domain-warning/domain-warning.tsx b/frontend/src/components/domain-warning/domain-warning.tsx index 975a3ab3..4f83b231 100644 --- a/frontend/src/components/domain-warning/domain-warning.tsx +++ b/frontend/src/components/domain-warning/domain-warning.tsx @@ -7,6 +7,7 @@ import { } from "../ui/card"; import { Button } from "../ui/button"; import { Trans, useTranslation } from "react-i18next"; +import { useLocation } from "react-router"; interface Props { onClick: () => void; @@ -17,9 +18,13 @@ interface Props { export const DomainWarning = (props: Props) => { const { onClick, appUrl, currentUrl } = props; const { t } = useTranslation(); + const { search } = useLocation(); + + const searchParams = new URLSearchParams(search); + const redirectUri = searchParams.get("redirect_uri"); return ( - + {t("domainWarningTitle")} @@ -31,10 +36,20 @@ export const DomainWarning = (props: Props) => { /> - + + ); diff --git a/frontend/src/lib/i18n/locales/en-US.json b/frontend/src/lib/i18n/locales/en-US.json index 7dfe4141..b2dd9001 100644 --- a/frontend/src/lib/i18n/locales/en-US.json +++ b/frontend/src/lib/i18n/locales/en-US.json @@ -54,5 +54,6 @@ "invalidInput": "Invalid input", "domainWarningTitle": "Invalid Domain", "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", - "ignoreTitle": "Ignore" + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 7dfe4141..b2dd9001 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -54,5 +54,6 @@ "invalidInput": "Invalid input", "domainWarningTitle": "Invalid Domain", "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", - "ignoreTitle": "Ignore" + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/pages/continue-page.tsx b/frontend/src/pages/continue-page.tsx index 4bb43b45..261be8b8 100644 --- a/frontend/src/pages/continue-page.tsx +++ b/frontend/src/pages/continue-page.tsx @@ -49,7 +49,7 @@ export const ContinuePage = () => { const handleRedirect = () => { setLoading(true); - window.location.replace(redirectUriObj!.toString()); + window.location.assign(redirectUriObj!.toString()); }; useEffect(() => { @@ -79,16 +79,21 @@ export const ContinuePage = () => { }, []); if (!isLoggedIn) { - return ; + return ( + + ); } if (!isValidRedirectUri || !isAllowedRedirectProto) { - return ; + return ; } if (!isTrustedRedirectUri) { return ( - + {t("continueUntrustedRedirectTitle")} @@ -126,7 +131,7 @@ export const ContinuePage = () => { if (isHttpsDowngrade) { return ( - + {t("continueInsecureRedirectTitle")} diff --git a/frontend/src/pages/login-page.tsx b/frontend/src/pages/login-page.tsx index 8fae53f3..fd7108cb 100644 --- a/frontend/src/pages/login-page.tsx +++ b/frontend/src/pages/login-page.tsx @@ -113,8 +113,17 @@ export const LoginPage = () => { [], ); + if (isLoggedIn && redirectUri) { + return ( + + ); + } + if (isLoggedIn) { - return ; + return ; } return ( diff --git a/frontend/src/pages/logout-page.tsx b/frontend/src/pages/logout-page.tsx index 6b52ed2b..17693bb6 100644 --- a/frontend/src/pages/logout-page.tsx +++ b/frontend/src/pages/logout-page.tsx @@ -32,7 +32,7 @@ export const LogoutPage = () => { }); redirectTimer.current = window.setTimeout(() => { - window.location.replace("/login"); + window.location.assign("/login"); }, 500); }, onError: () => { @@ -50,7 +50,7 @@ export const LogoutPage = () => { ); if (!isLoggedIn) { - return ; + return ; } return ( diff --git a/frontend/src/pages/totp-page.tsx b/frontend/src/pages/totp-page.tsx index 3306b52c..ef055652 100644 --- a/frontend/src/pages/totp-page.tsx +++ b/frontend/src/pages/totp-page.tsx @@ -57,7 +57,7 @@ export const TotpPage = () => { ); if (!totpPending) { - return ; + return ; } return ( diff --git a/internal/controller/context_controller.go b/internal/controller/context_controller.go index f2bd068e..7cea62fe 100644 --- a/internal/controller/context_controller.go +++ b/internal/controller/context_controller.go @@ -1,6 +1,8 @@ package controller import ( + "fmt" + "net/url" "tinyauth/internal/utils" "github.com/gin-gonic/gin" @@ -34,7 +36,6 @@ type AppContextResponse struct { type ContextControllerConfig struct { ConfiguredProviders []string - DisableContinue bool Title string GenericName string AppURL string @@ -90,13 +91,15 @@ func (controller *ContextController) userContextHandler(c *gin.Context) { } func (controller *ContextController) appContextHandler(c *gin.Context) { + appUrl, _ := url.Parse(controller.Config.AppURL) // no need to check error, validated on startup + c.JSON(200, AppContextResponse{ Status: 200, Message: "Success", ConfiguredProviders: controller.Config.ConfiguredProviders, Title: controller.Config.Title, GenericName: controller.Config.GenericName, - AppURL: controller.Config.AppURL, + AppURL: fmt.Sprintf("%s://%s", appUrl.Scheme, appUrl.Host), RootDomain: controller.Config.RootDomain, ForgotPasswordMessage: controller.Config.ForgotPasswordMessage, BackgroundImage: controller.Config.BackgroundImage, From 95f8a95fd361e47e334948755922d87b01510c49 Mon Sep 17 00:00:00 2001 From: Stavros Date: Mon, 1 Sep 2025 18:19:57 +0300 Subject: [PATCH 8/8] refactor: rename domain to root domain --- internal/bootstrap/app_bootstrap.go | 18 ++++++++++-------- internal/controller/oauth_controller.go | 14 +++++++------- internal/controller/user_controller.go | 8 ++++---- internal/middleware/context_middleware.go | 6 +++--- internal/service/auth_service.go | 6 +++--- internal/utils/app_utils.go | 6 +++--- 6 files changed, 30 insertions(+), 28 deletions(-) diff --git a/internal/bootstrap/app_bootstrap.go b/internal/bootstrap/app_bootstrap.go index e941772b..d2ac1b0a 100644 --- a/internal/bootstrap/app_bootstrap.go +++ b/internal/bootstrap/app_bootstrap.go @@ -2,6 +2,7 @@ package bootstrap import ( "fmt" + "net/url" "strings" "tinyauth/internal/config" "tinyauth/internal/controller" @@ -44,15 +45,16 @@ func (app *BootstrapApp) Setup() error { return err } - // Get domain - domain, err := utils.GetUpperDomain(app.Config.AppURL) + // Get root domain + rootDomain, err := utils.GetRootDomain(app.Config.AppURL) if err != nil { return err } // Cookie names - cookieId := utils.GenerateIdentifier(strings.Split(domain, ".")[0]) + appUrl, _ := url.Parse(app.Config.AppURL) // Already validated + cookieId := utils.GenerateIdentifier(appUrl.Hostname()) sessionCookieName := fmt.Sprintf("%s-%s", config.SessionCookieName, cookieId) csrfCookieName := fmt.Sprintf("%s-%s", config.CSRFCookieName, cookieId) redirectCookieName := fmt.Sprintf("%s-%s", config.RedirectCookieName, cookieId) @@ -63,7 +65,7 @@ func (app *BootstrapApp) Setup() error { OauthWhitelist: app.Config.OAuthWhitelist, SessionExpiry: app.Config.SessionExpiry, SecureCookie: app.Config.SecureCookie, - Domain: domain, + RootDomain: rootDomain, LoginTimeout: app.Config.LoginTimeout, LoginMaxRetries: app.Config.LoginMaxRetries, SessionCookieName: sessionCookieName, @@ -153,7 +155,7 @@ func (app *BootstrapApp) Setup() error { var middlewares []Middleware contextMiddleware := middleware.NewContextMiddleware(middleware.ContextMiddlewareConfig{ - Domain: domain, + RootDomain: rootDomain, }, authService, oauthBrokerService) uiMiddleware := middleware.NewUIMiddleware() @@ -180,7 +182,7 @@ func (app *BootstrapApp) Setup() error { Title: app.Config.Title, GenericName: app.Config.GenericName, AppURL: app.Config.AppURL, - RootDomain: domain, + RootDomain: rootDomain, ForgotPasswordMessage: app.Config.ForgotPasswordMessage, BackgroundImage: app.Config.BackgroundImage, OAuthAutoRedirect: app.Config.OAuthAutoRedirect, @@ -191,7 +193,7 @@ func (app *BootstrapApp) Setup() error { SecureCookie: app.Config.SecureCookie, CSRFCookieName: csrfCookieName, RedirectCookieName: redirectCookieName, - Domain: domain, + RootDomain: rootDomain, }, apiRouter, authService, oauthBrokerService) proxyController := controller.NewProxyController(controller.ProxyControllerConfig{ @@ -199,7 +201,7 @@ func (app *BootstrapApp) Setup() error { }, apiRouter, dockerService, authService) userController := controller.NewUserController(controller.UserControllerConfig{ - Domain: domain, + RootDomain: rootDomain, }, apiRouter, authService) resourcesController := controller.NewResourcesController(controller.ResourcesControllerConfig{ diff --git a/internal/controller/oauth_controller.go b/internal/controller/oauth_controller.go index 31b21f09..cfac6567 100644 --- a/internal/controller/oauth_controller.go +++ b/internal/controller/oauth_controller.go @@ -23,7 +23,7 @@ type OAuthControllerConfig struct { RedirectCookieName string SecureCookie bool AppURL string - Domain string + RootDomain string } type OAuthController struct { @@ -74,13 +74,13 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) { state := service.GenerateState() authURL := service.GetAuthURL(state) - c.SetCookie(controller.Config.CSRFCookieName, state, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.Config.Domain), controller.Config.SecureCookie, true) + c.SetCookie(controller.Config.CSRFCookieName, state, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.Config.RootDomain), controller.Config.SecureCookie, true) redirectURI := c.Query("redirect_uri") - if redirectURI != "" && utils.IsRedirectSafe(redirectURI, controller.Config.Domain) { + if redirectURI != "" && utils.IsRedirectSafe(redirectURI, controller.Config.RootDomain) { log.Debug().Msg("Setting redirect URI cookie") - c.SetCookie(controller.Config.RedirectCookieName, redirectURI, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.Config.Domain), controller.Config.SecureCookie, true) + c.SetCookie(controller.Config.RedirectCookieName, redirectURI, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.Config.RootDomain), controller.Config.SecureCookie, true) } c.JSON(200, gin.H{ @@ -112,7 +112,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { return } - c.SetCookie(controller.Config.CSRFCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.Config.Domain), controller.Config.SecureCookie, true) + c.SetCookie(controller.Config.CSRFCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.Config.RootDomain), controller.Config.SecureCookie, true) code := c.Query("code") service, exists := controller.Broker.GetService(req.Provider) @@ -189,7 +189,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { redirectURI, err := c.Cookie(controller.Config.RedirectCookieName) - if err != nil || !utils.IsRedirectSafe(redirectURI, controller.Config.Domain) { + if err != nil || !utils.IsRedirectSafe(redirectURI, controller.Config.RootDomain) { log.Debug().Msg("No redirect URI cookie found, redirecting to app root") c.Redirect(http.StatusTemporaryRedirect, controller.Config.AppURL) return @@ -205,6 +205,6 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { return } - c.SetCookie(controller.Config.RedirectCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.Config.Domain), controller.Config.SecureCookie, true) + c.SetCookie(controller.Config.RedirectCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.Config.RootDomain), controller.Config.SecureCookie, true) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/continue?%s", controller.Config.AppURL, queries.Encode())) } diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go index f7f7c9e6..f3b7b515 100644 --- a/internal/controller/user_controller.go +++ b/internal/controller/user_controller.go @@ -22,7 +22,7 @@ type TotpRequest struct { } type UserControllerConfig struct { - Domain string + RootDomain string } type UserController struct { @@ -115,7 +115,7 @@ func (controller *UserController) loginHandler(c *gin.Context) { err := controller.Auth.CreateSessionCookie(c, &config.SessionCookie{ Username: user.Username, Name: utils.Capitalize(req.Username), - Email: fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.Config.Domain), + Email: fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.Config.RootDomain), Provider: "username", TotpPending: true, }) @@ -141,7 +141,7 @@ func (controller *UserController) loginHandler(c *gin.Context) { err = controller.Auth.CreateSessionCookie(c, &config.SessionCookie{ Username: req.Username, Name: utils.Capitalize(req.Username), - Email: fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.Config.Domain), + Email: fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.Config.RootDomain), Provider: "username", }) @@ -246,7 +246,7 @@ func (controller *UserController) totpHandler(c *gin.Context) { err = controller.Auth.CreateSessionCookie(c, &config.SessionCookie{ Username: user.Username, Name: utils.Capitalize(user.Username), - Email: fmt.Sprintf("%s@%s", strings.ToLower(user.Username), controller.Config.Domain), + Email: fmt.Sprintf("%s@%s", strings.ToLower(user.Username), controller.Config.RootDomain), Provider: "username", }) diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index ee8932a2..bca0400f 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -12,7 +12,7 @@ import ( ) type ContextMiddlewareConfig struct { - Domain string + RootDomain string } type ContextMiddleware struct { @@ -134,7 +134,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { c.Set("context", &config.UserContext{ Username: user.Username, Name: utils.Capitalize(user.Username), - Email: fmt.Sprintf("%s@%s", strings.ToLower(user.Username), m.Config.Domain), + Email: fmt.Sprintf("%s@%s", strings.ToLower(user.Username), m.Config.RootDomain), Provider: "basic", IsLoggedIn: true, TotpEnabled: user.TotpSecret != "", @@ -146,7 +146,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { c.Set("context", &config.UserContext{ Username: basic.Username, Name: utils.Capitalize(basic.Username), - Email: fmt.Sprintf("%s@%s", strings.ToLower(basic.Username), m.Config.Domain), + Email: fmt.Sprintf("%s@%s", strings.ToLower(basic.Username), m.Config.RootDomain), Provider: "basic", IsLoggedIn: true, }) diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index c7a27c1f..f028149b 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -28,7 +28,7 @@ type AuthServiceConfig struct { OauthWhitelist string SessionExpiry int SecureCookie bool - Domain string + RootDomain string LoginTimeout int LoginMaxRetries int SessionCookieName string @@ -216,7 +216,7 @@ func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *config.Sessio return err } - c.SetCookie(auth.Config.SessionCookieName, session.UUID, expiry, "/", fmt.Sprintf(".%s", auth.Config.Domain), auth.Config.SecureCookie, true) + c.SetCookie(auth.Config.SessionCookieName, session.UUID, expiry, "/", fmt.Sprintf(".%s", auth.Config.RootDomain), auth.Config.SecureCookie, true) return nil } @@ -234,7 +234,7 @@ func (auth *AuthService) DeleteSessionCookie(c *gin.Context) error { return res.Error } - c.SetCookie(auth.Config.SessionCookieName, "", -1, "/", fmt.Sprintf(".%s", auth.Config.Domain), auth.Config.SecureCookie, true) + c.SetCookie(auth.Config.SessionCookieName, "", -1, "/", fmt.Sprintf(".%s", auth.Config.RootDomain), auth.Config.SecureCookie, true) return nil } diff --git a/internal/utils/app_utils.go b/internal/utils/app_utils.go index 85a87542..62b95922 100644 --- a/internal/utils/app_utils.go +++ b/internal/utils/app_utils.go @@ -12,8 +12,8 @@ import ( "github.com/rs/zerolog" ) -// Get upper domain parses a hostname and returns the upper domain (e.g. sub1.sub2.domain.com -> sub2.domain.com) -func GetUpperDomain(appUrl string) (string, error) { +// Get root domain parses a hostname and returns the upper domain (e.g. sub1.sub2.domain.com -> sub2.domain.com) +func GetRootDomain(appUrl string) (string, error) { appUrlParsed, err := url.Parse(appUrl) if err != nil { return "", err @@ -88,7 +88,7 @@ func IsRedirectSafe(redirectURL string, domain string) bool { return false } - upper, err := GetUpperDomain(redirectURL) + upper, err := GetRootDomain(redirectURL) if err != nil { return false