Skip to content

Commit b972191

Browse files
committed
Move MockEasyAuth picker to React with hero layout and dropdown controls
1 parent 209939a commit b972191

14 files changed

Lines changed: 489 additions & 60 deletions

File tree

application/account/Api/Program.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@
3737
var backOfficePublicUrl = appPublicUrl is null ? null : ReplaceHost(appPublicUrl, appHostname, backOfficeHostname);
3838
var backOfficeCdnUrl = backOfficePublicUrl;
3939

40+
// The /login picker is the dev-only MockEasyAuth identity selector. In Azure-deployed instances the
41+
// path must not be reachable: it is removed from the auth-gate exemption list (so the back-office
42+
// authorize policy applies) and short-circuited to 401 below to reject even authenticated requests.
43+
string[] backOfficeUnauthenticatedPaths = SharedInfrastructureConfiguration.IsRunningInAzure ? [] : ["/login"];
44+
45+
if (SharedInfrastructureConfiguration.IsRunningInAzure)
46+
{
47+
app.MapGet("/login", Results.Unauthorized).RequireHost(backOfficeHostname);
48+
}
49+
4050
app
4151
.UseApiServices() // Add common configuration for all APIs like Swagger, HSTS, and DeveloperExceptionPage.
4252
.UseHostScopedSinglePageAppFallback(
@@ -53,7 +63,8 @@
5363
BuildBackOfficeUserInfo,
5464
backOfficePublicUrl,
5565
backOfficeCdnUrl,
56-
BackOfficeIdentityDefaults.PolicyName
66+
BackOfficeIdentityDefaults.PolicyName,
67+
unauthenticatedPaths: backOfficeUnauthenticatedPaths
5768
)
5869
);
5970

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { t } from "@lingui/core/macro";
2+
import { useLingui } from "@lingui/react";
3+
import { Trans } from "@lingui/react/macro";
4+
import { preferredLocaleKey } from "@repo/infrastructure/translations/constants";
5+
import localeMap from "@repo/infrastructure/translations/i18n.config.json";
6+
import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext";
7+
import { Button } from "@repo/ui/components/Button";
8+
import {
9+
DropdownMenu,
10+
DropdownMenuContent,
11+
DropdownMenuItem,
12+
DropdownMenuTrigger
13+
} from "@repo/ui/components/DropdownMenu";
14+
import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip";
15+
import { CheckIcon, GlobeIcon, MoonIcon, MoonStarIcon, SunIcon, SunMoonIcon } from "lucide-react";
16+
import { useTheme } from "next-themes";
17+
import { use } from "react";
18+
19+
export function LoginFooterControls() {
20+
return (
21+
<div className="flex gap-4 rounded-md bg-card p-2 shadow-md">
22+
<ThemeDropdown />
23+
<LocaleDropdown />
24+
</div>
25+
);
26+
}
27+
28+
function ThemeDropdown() {
29+
const { theme, setTheme, resolvedTheme } = useTheme();
30+
31+
const themeIcon =
32+
theme === "dark" ? (
33+
<MoonIcon className="size-5" />
34+
) : theme === "light" ? (
35+
<SunIcon className="size-5" />
36+
) : resolvedTheme === "dark" ? (
37+
<MoonStarIcon className="size-5" />
38+
) : (
39+
<SunMoonIcon className="size-5" />
40+
);
41+
42+
return (
43+
<DropdownMenu trackingTitle="Theme menu">
44+
<Tooltip>
45+
<TooltipTrigger
46+
render={
47+
<DropdownMenuTrigger
48+
render={
49+
<Button variant="ghost" size="icon" aria-label={t`Change theme`}>
50+
{themeIcon}
51+
</Button>
52+
}
53+
/>
54+
}
55+
/>
56+
<TooltipContent>{t`Change theme`}</TooltipContent>
57+
</Tooltip>
58+
<DropdownMenuContent align="start">
59+
<DropdownMenuItem trackingLabel="System" onClick={() => setTheme("system")}>
60+
{resolvedTheme === "dark" ? <MoonStarIcon className="size-5" /> : <SunMoonIcon className="size-5" />}
61+
<Trans>System</Trans>
62+
{theme === "system" && <CheckIcon className="ml-auto size-4" />}
63+
</DropdownMenuItem>
64+
<DropdownMenuItem trackingLabel="Light" onClick={() => setTheme("light")}>
65+
<SunIcon className="size-5" />
66+
<Trans>Light</Trans>
67+
{theme === "light" && <CheckIcon className="ml-auto size-4" />}
68+
</DropdownMenuItem>
69+
<DropdownMenuItem trackingLabel="Dark" onClick={() => setTheme("dark")}>
70+
<MoonIcon className="size-5" />
71+
<Trans>Dark</Trans>
72+
{theme === "dark" && <CheckIcon className="ml-auto size-4" />}
73+
</DropdownMenuItem>
74+
</DropdownMenuContent>
75+
</DropdownMenu>
76+
);
77+
}
78+
79+
function LocaleDropdown() {
80+
const { setLocale } = use(translationContext);
81+
const { i18n } = useLingui();
82+
const currentLocale = i18n.locale as Locale;
83+
const locales = Object.keys(localeMap) as Locale[];
84+
const getLocaleInfo = (locale: Locale) => localeMap[locale];
85+
86+
const handleLocaleChange = (locale: Locale) => {
87+
if (locale !== currentLocale) {
88+
setLocale(locale).then(() => {
89+
localStorage.setItem(preferredLocaleKey, locale);
90+
});
91+
}
92+
};
93+
94+
return (
95+
<DropdownMenu trackingTitle="Language menu">
96+
<Tooltip>
97+
<TooltipTrigger
98+
render={
99+
<DropdownMenuTrigger
100+
render={
101+
<Button variant="ghost" size="icon" aria-label={t`Change language`}>
102+
<GlobeIcon className="size-5" />
103+
</Button>
104+
}
105+
/>
106+
}
107+
/>
108+
<TooltipContent>{t`Change language`}</TooltipContent>
109+
</Tooltip>
110+
<DropdownMenuContent>
111+
{locales.map((locale) => (
112+
<DropdownMenuItem
113+
key={locale}
114+
trackingLabel={getLocaleInfo(locale).label}
115+
onClick={() => handleLocaleChange(locale)}
116+
>
117+
<span>{getLocaleInfo(locale).label}</span>
118+
{locale === currentLocale && <CheckIcon className="ml-auto size-4" />}
119+
</DropdownMenuItem>
120+
))}
121+
</DropdownMenuContent>
122+
</DropdownMenu>
123+
);
124+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { t } from "@lingui/core/macro";
2+
import { Trans } from "@lingui/react/macro";
3+
import { NotFoundError } from "@repo/infrastructure/auth/routeGuards";
4+
import { Button } from "@repo/ui/components/Button";
5+
import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@repo/ui/components/Field";
6+
import { Link } from "@repo/ui/components/Link";
7+
import { RadioGroup, RadioGroupItem } from "@repo/ui/components/RadioGroup";
8+
import { createFileRoute } from "@tanstack/react-router";
9+
import { useState } from "react";
10+
11+
import logoMarkUrl from "@/shared/images/logo-mark.svg";
12+
import { HorizontalHeroLayout } from "@/shared/layouts/HorizontalHeroLayout";
13+
14+
const MOCK_IDENTITY_IDS = ["admin", "support", "readonly", "plain"] as const;
15+
16+
interface MockLoginSearch {
17+
returnPath: string;
18+
}
19+
20+
export const Route = createFileRoute("/login")({
21+
staticData: { trackingTitle: "Mock login" },
22+
beforeLoad: () => {
23+
if (process.env.NODE_ENV === "production") {
24+
throw new NotFoundError();
25+
}
26+
},
27+
validateSearch: (search: Record<string, unknown>): MockLoginSearch => {
28+
const raw = search.returnPath;
29+
const returnPath = typeof raw === "string" && raw.startsWith("/") ? raw : "/";
30+
return { returnPath };
31+
},
32+
component: MockLoginPage
33+
});
34+
35+
function MockLoginPage() {
36+
const { returnPath } = Route.useSearch();
37+
const [selectedId, setSelectedId] = useState<string>("admin");
38+
const [isPending, setIsPending] = useState(false);
39+
40+
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
41+
event.preventDefault();
42+
setIsPending(true);
43+
const encodedReturnPath = encodeURIComponent(returnPath);
44+
globalThis.location.href = `/.auth/login/aad/callback?identity=${selectedId}&post_login_redirect_uri=${encodedReturnPath}`;
45+
};
46+
47+
return (
48+
<HorizontalHeroLayout>
49+
<form onSubmit={handleSubmit} className="flex w-full max-w-[22rem] flex-col items-center gap-4 pt-8 pb-4">
50+
<Link href="/" className="cursor-pointer">
51+
<img src={logoMarkUrl} className="size-12" alt={t`Logo`} />
52+
</Link>
53+
<h2>
54+
<Trans>BackOffice - Localhost</Trans>
55+
</h2>
56+
<div className="text-center text-sm text-muted-foreground">
57+
<Trans>
58+
Local development sign-in. Production uses Azure Container Apps built-in Entra ID authentication.
59+
</Trans>
60+
</div>
61+
62+
<RadioGroup className="w-full pt-2" value={selectedId} onValueChange={setSelectedId}>
63+
{MOCK_IDENTITY_IDS.map((id) => (
64+
<FieldLabel key={id}>
65+
<Field orientation="horizontal">
66+
<RadioGroupItem value={id} />
67+
<FieldContent>
68+
<FieldTitle>{getIdentityName(id)}</FieldTitle>
69+
<FieldDescription>{getIdentityDescription(id)}</FieldDescription>
70+
</FieldContent>
71+
</Field>
72+
</FieldLabel>
73+
))}
74+
</RadioGroup>
75+
76+
<Button type="submit" isPending={isPending} className="mt-4 w-full text-center">
77+
{isPending ? <Trans>Logging in...</Trans> : <Trans>Log in</Trans>}
78+
</Button>
79+
</form>
80+
</HorizontalHeroLayout>
81+
);
82+
}
83+
84+
function getIdentityName(id: string) {
85+
switch (id) {
86+
case "admin":
87+
return <Trans>Admin User</Trans>;
88+
case "support":
89+
return <Trans>Support User</Trans>;
90+
case "readonly":
91+
return <Trans>Read Only</Trans>;
92+
case "plain":
93+
return <Trans>Plain User</Trans>;
94+
default:
95+
return id;
96+
}
97+
}
98+
99+
function getIdentityDescription(id: string) {
100+
switch (id) {
101+
case "admin":
102+
return <Trans>Log in with admin rights</Trans>;
103+
case "support":
104+
return <Trans>Log in with support rights</Trans>;
105+
case "readonly":
106+
return <Trans>Log in with read-only rights</Trans>;
107+
case "plain":
108+
return <Trans>Log in without group claims</Trans>;
109+
default:
110+
return null;
111+
}
112+
}
1.15 KB
Loading
68.9 KB
Loading
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { ReactNode } from "react";
2+
3+
import { t } from "@lingui/core/macro";
4+
5+
import { LoginFooterControls } from "@/routes/-components/LoginFooterControls";
6+
import heroDesktopBlurImage from "@/shared/images/hero-desktop-blur.webp";
7+
import heroDesktopImage from "@/shared/images/hero-desktop-xl.webp";
8+
9+
interface HorizontalHeroLayoutProps {
10+
children: ReactNode;
11+
}
12+
13+
export function HorizontalHeroLayout({ children }: Readonly<HorizontalHeroLayoutProps>) {
14+
return (
15+
<main className="relative flex min-h-screen flex-col">
16+
<div className="absolute top-4 right-4 hidden lg:block">
17+
<LoginFooterControls />
18+
</div>
19+
<div className="flex grow flex-col gap-4 lg:flex-row">
20+
<div className="flex min-h-screen w-full flex-col bg-background p-6 lg:min-h-0 lg:w-1/2">
21+
<div style={{ flex: 1 }} className="flex flex-col items-center justify-center gap-6">
22+
{children}
23+
<div className="lg:hidden">
24+
<LoginFooterControls />
25+
</div>
26+
</div>
27+
</div>
28+
<div className="hidden items-center justify-center bg-input-background p-6 lg:flex lg:w-1/2 lg:px-28 lg:py-12">
29+
<div
30+
className="h-auto w-full max-w-[64rem] bg-cover bg-center bg-no-repeat"
31+
style={{ backgroundImage: `url(${heroDesktopBlurImage})`, aspectRatio: "1000/760" }}
32+
>
33+
<img
34+
src={heroDesktopImage}
35+
alt={t`Screenshots of the dashboard project with desktop and mobile versions`}
36+
fetchPriority="high"
37+
className="h-auto w-full"
38+
/>
39+
</div>
40+
</div>
41+
</div>
42+
</main>
43+
);
44+
}

application/account/BackOfficeWebApp/shared/translations/locale/da-DK.po

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,18 @@ msgstr "Konti"
1919
msgid "Accounts (coming soon)"
2020
msgstr "Konti (kommer snart)"
2121

22+
msgid "Admin User"
23+
msgstr "Administrator"
24+
2225
msgid "An unexpected error occurred while processing your request."
2326
msgstr "Der opstod en uventet fejl ved behandlingen."
2427

2528
msgid "Back Office"
2629
msgstr "Back Office"
2730

31+
msgid "BackOffice - Localhost"
32+
msgstr "BackOffice - Localhost"
33+
2834
msgid "Change language"
2935
msgstr "Skift sprog"
3036

@@ -76,9 +82,33 @@ msgstr "Større"
7682
msgid "Light"
7783
msgstr "Lys"
7884

85+
msgid "Local development sign-in. Production uses Azure Container Apps built-in Entra ID authentication."
86+
msgstr "Login til lokal udvikling. Produktion bruger Azure Container Apps' indbyggede Entra ID-godkendelse."
87+
88+
msgid "Log in"
89+
msgstr "Log ind"
90+
91+
msgid "Log in with admin rights"
92+
msgstr "Log ind med administratorrettigheder"
93+
94+
msgid "Log in with read-only rights"
95+
msgstr "Log ind med skrivebeskyttede rettigheder"
96+
97+
msgid "Log in with support rights"
98+
msgstr "Log ind med supportrettigheder"
99+
100+
msgid "Log in without group claims"
101+
msgstr "Log ind uden gruppeclaims"
102+
79103
msgid "Log out"
80104
msgstr "Log ud"
81105

106+
msgid "Logging in..."
107+
msgstr "Logger ind..."
108+
109+
msgid "Logo"
110+
msgstr "Logo"
111+
82112
msgid "Main navigation"
83113
msgstr "Hovednavigation"
84114

@@ -97,6 +127,9 @@ msgstr "Ingen"
97127
msgid "Page not found"
98128
msgstr "Siden blev ikke fundet"
99129

130+
msgid "Plain User"
131+
msgstr "Almindelig bruger"
132+
100133
msgid "PlatformPlatform logo"
101134
msgstr "PlatformPlatform logo"
102135

@@ -106,6 +139,12 @@ msgstr "Tjek venligst URL'en eller vend tilbage til forsiden."
106139
msgid "Please try again or return to the home page."
107140
msgstr "Prøv venligst igen eller vend tilbage til forsiden."
108141

142+
msgid "Read Only"
143+
msgstr "Skrivebeskyttet"
144+
145+
msgid "Screenshots of the dashboard project with desktop and mobile versions"
146+
msgstr "Skærmbilleder af dashboard-projektet i desktop- og mobilversioner"
147+
109148
msgid "Show details"
110149
msgstr "Vis detaljer"
111150

@@ -121,6 +160,9 @@ msgstr "Support"
121160
msgid "Support (coming soon)"
122161
msgstr "Support (kommer snart)"
123162

163+
msgid "Support User"
164+
msgstr "Supportbruger"
165+
124166
msgid "System"
125167
msgstr "System"
126168

0 commit comments

Comments
 (0)