diff --git a/copier.yaml b/copier.yaml index f7625ca..95917ff 100644 --- a/copier.yaml +++ b/copier.yaml @@ -37,6 +37,17 @@ keyprefix: help: The prefix for various keys used in the app default: "{{ appname | lower | replace('_', '-') }}" +identity: + type: bool + help: Whether to include identity features in the app + default: true + +access: + type: bool + help: Whether to include access features in the app + default: true + when: "{{ identity }}" + docs: type: bool help: Whether to include a documentation site for the app diff --git a/src/docker-compose.yaml.jinja b/src/docker-compose.yaml.jinja index c4901a7..68784c5 100644 --- a/src/docker-compose.yaml.jinja +++ b/src/docker-compose.yaml.jinja @@ -9,6 +9,9 @@ services: - "{{ envprefix }}__APIS__ICANHAZDADJOKE__PORT=${ {{- envprefix }}__APIS__ICANHAZDADJOKE__PORT:-}" - "{{ envprefix }}__APIS__ICANHAZDADJOKE__SCHEME=${ {{- envprefix }}__APIS__ICANHAZDADJOKE__SCHEME:-https}" - "{{ envprefix }}__DEBUG=${ {{- envprefix }}__DEBUG:-true}" + {%- if identity %} + - "{{ envprefix }}__IDENTITY__USERS__DEBUG__ID=${ {{- envprefix }}__IDENTITY__USERS__DEBUG__ID:-debug}" + {%- endif %} - "{{ envprefix }}__SERVER__HOST=${ {{- envprefix }}__SERVER__HOST:-0.0.0.0}" - "{{ envprefix }}__SERVER__PORT=${ {{- envprefix }}__SERVER__PORT:-{{ port -}} }" network_mode: host diff --git a/src/next.config.ts b/src/next.config.ts index 9c6237c..0403805 100644 --- a/src/next.config.ts +++ b/src/next.config.ts @@ -13,6 +13,9 @@ export default { distDir: "build", experimental: { + // Enable authentication and authorization interrupts + authInterrupts: true, + // Optimize import from Mantine packages optimizePackageImports: [ "@mantine/core", diff --git a/src/src/app/(root)/(main)/(home)/page.tsx.jinja b/src/src/app/(root)/(main)/(home)/page.tsx.jinja index ea6e8eb..0bfa7ee 100644 --- a/src/src/app/(root)/(main)/(home)/page.tsx.jinja +++ b/src/src/app/(root)/(main)/(home)/page.tsx.jinja @@ -9,6 +9,9 @@ import type { import type { Keys } from "./types"; import { Metadata } from "../../../../isomorphic/metadata/components/metadata"; +{%- if identity and access %} +import { Authenticated } from "../../../../server/access/components/authenticated"; +{%- endif %} import { createMetadata } from "../../../../server/metadata/lib/create-metadata"; import { HomePageView } from "./page.view"; import { Schemas } from "./schemas"; @@ -38,9 +41,9 @@ export default async function HomePage({ const queryParameters = await Schemas.Query.parseAsync(await searchParams); return ( - <> + <{% if identity and access %}Authenticated{% endif %}> - + ); } diff --git a/src/src/app/(root)/layout.tsx.jinja b/src/src/app/(root)/layout.tsx.jinja index 5e1b0ec..4be80d5 100644 --- a/src/src/app/(root)/layout.tsx.jinja +++ b/src/src/app/(root)/layout.tsx.jinja @@ -14,12 +14,18 @@ import type { Schemas } from "./schemas"; import type { Keys } from "./types"; import { ThemeScript } from "../../common/theme/components/theme-script"; +{%- if identity %} +import { IdentityProvider } from "../../isomorphic/identity/components/identity-provider"; +{%- endif %} import { LocalizationProvider } from "../../isomorphic/localization/components/localization-provider"; import { Metadata } from "../../isomorphic/metadata/components/metadata"; import { MetadataProvider } from "../../isomorphic/metadata/components/metadata-provider"; import { QueryProvider } from "../../isomorphic/query/components/query-provider"; import { StateProvider } from "../../isomorphic/state/components/state-provider"; import { ThemeProvider } from "../../isomorphic/theme/components/theme-provider"; +{%- if identity %} +import { getIdentity } from "../../server/identity/lib/get-identity"; +{%- endif %} import { resolveLocale } from "../../server/localization/lib/resolve-locale"; import { createMetadata } from "../../server/metadata/lib/create-metadata"; import { createViewport } from "../../server/metadata/lib/create-viewport"; @@ -80,6 +86,9 @@ export default async function RootLayout({ const { queryClient } = getQueryClient(); const { locale } = await resolveLocale({ queryClient: queryClient }); + {%- if identity %} + const { identity } = await getIdentity({ queryClient: queryClient }); + {%- endif %} return ( @@ -97,11 +106,21 @@ export default async function RootLayout({ primaryColor={constants.colors.primary.name} primaryShade={constants.colors.primary.shade} > + {%- if identity %} + + + {children} + + {%- else %} {children} + {%- endif %} diff --git a/src/src/app/(root)/layout.view.tsx b/src/src/app/(root)/layout.view.tsx deleted file mode 100644 index 793d7dd..0000000 --- a/src/src/app/(root)/layout.view.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { LayoutViewInput } from "../types"; -import type { Schemas } from "./schemas"; -import type { Keys } from "./types"; - -export async function RootLayoutView({ - children, -}: LayoutViewInput) { - return children; -} diff --git a/src/src/app/(root)/layout.view.tsx.jinja b/src/src/app/(root)/layout.view.tsx.jinja new file mode 100644 index 0000000..70ef89e --- /dev/null +++ b/src/src/app/(root)/layout.view.tsx.jinja @@ -0,0 +1,23 @@ +import type { LayoutViewInput } from "../types"; +import type { Schemas } from "./schemas"; +import type { Keys } from "./types"; + +import { PageLayout } from "../../common/core/components/generic/page-layout"; +{%- if identity %} +import { IdentityWidget } from "../../isomorphic/identity/components/identity-widget"; +{%- endif %} + +export async function RootLayoutView({ + children, +}: LayoutViewInput) { + {%- if identity %} + return ( + + + {children} + + ); + {%- else %} + return {children}; + {%- endif %} +} diff --git a/src/src/app/(root)/{% if identity and access %}forbidden.tsx{% endif %}.jinja b/src/src/app/(root)/{% if identity and access %}forbidden.tsx{% endif %}.jinja new file mode 100644 index 0000000..f18f5b6 --- /dev/null +++ b/src/src/app/(root)/{% if identity and access %}forbidden.tsx{% endif %}.jinja @@ -0,0 +1,21 @@ +"use client"; + +import { msg } from "@lingui/core/macro"; + +import type { ForbiddenInput, ForbiddenMetadataUtilityInput } from "../types"; + +import { Metadata } from "../../isomorphic/metadata/components/metadata"; +import { RootForbiddenView } from "./forbidden.view"; + +function getTitle({}: ForbiddenMetadataUtilityInput = {}) { + return msg({ message: "Forbidden • {{ appname }}" }); +} + +export default function RootForbidden({}: ForbiddenInput) { + return ( + <> + + + + ); +} diff --git a/src/src/app/(root)/{% if identity and access %}forbidden.view.tsx{% endif %} b/src/src/app/(root)/{% if identity and access %}forbidden.view.tsx{% endif %} new file mode 100644 index 0000000..7031994 --- /dev/null +++ b/src/src/app/(root)/{% if identity and access %}forbidden.view.tsx{% endif %} @@ -0,0 +1,12 @@ +import type { ForbiddenViewInput } from "../types"; + +import { MainLayout } from "../../common/core/components/generic/main-layout"; +import { ForbiddenWidget } from "../../isomorphic/access/components/forbidden-widget"; + +export function RootForbiddenView({}: ForbiddenViewInput) { + return ( + + + + ); +} diff --git a/src/src/app/global-error.view.tsx b/src/src/app/global-error.view.tsx index 380ef6a..ef19770 100644 --- a/src/src/app/global-error.view.tsx +++ b/src/src/app/global-error.view.tsx @@ -1,12 +1,15 @@ import type { ErrorViewInput } from "./types"; import { SafeMainLayout } from "../common/core/components/generic/safe-main-layout"; +import { SafePageLayout } from "../common/core/components/generic/safe-page-layout"; import { SafeErrorWidget } from "../isomorphic/core/components/generic/safe-error-widget"; export function GlobalErrorView({ reset }: ErrorViewInput) { return ( - - - + + + + + ); } diff --git a/src/src/app/types.ts b/src/src/app/types.ts index 897e286..d886634 100644 --- a/src/src/app/types.ts +++ b/src/src/app/types.ts @@ -48,6 +48,8 @@ export type ErrorInput = { reset: () => void; }; +export type ForbiddenInput = object; + export type LayoutInput< PathParametersKeysType extends string = never, SlotsKeysType extends string = never, @@ -77,8 +79,12 @@ export type RouteInput = { export type TemplateInput = Simplify>; +export type UnauthorizedInput = object; + export type ErrorMetadataUtilityInput = object; +export type ForbiddenMetadataUtilityInput = object; + export type LayoutMetadataInput = { params: Promise>; @@ -120,8 +126,12 @@ export type PageMetadataUtilityInput< }) >; +export type UnauthorizedMetadataUtilityInput = object; + export type ErrorViewportUtilityInput = object; +export type ForbiddenViewportUtilityInput = object; + export type LayoutViewportInput = { params: Promise>; @@ -163,6 +173,8 @@ export type PageViewportUtilityInput< }) >; +export type UnauthorizedViewportUtilityInput = object; + export type DefaultViewInput< PathParametersSchemaType extends z.core.$ZodObject = never, > = [PathParametersSchemaType] extends [never] @@ -173,6 +185,8 @@ export type ErrorViewInput = { reset: () => void; }; +export type ForbiddenViewInput = object; + export type LayoutViewInput< PathParametersSchemaType extends z.core.$ZodObject = never, SlotsKeysType extends string = never, @@ -208,3 +222,5 @@ export type PageViewInput< >; export type TemplateViewInput = Simplify>; + +export type UnauthorizedViewInput = object; diff --git a/src/src/common/core/components/generic/main-layout/main.tsx b/src/src/common/core/components/generic/main-layout/main.tsx index a27183a..7d6784c 100644 --- a/src/src/common/core/components/generic/main-layout/main.tsx +++ b/src/src/common/core/components/generic/main-layout/main.tsx @@ -1,17 +1,9 @@ import { Box } from "@mantine/core"; -import { Notifications } from "@mantine/notifications"; import type { MainLayoutInput } from "./types"; import classes from "./styles.module.css"; export function MainLayout({ children }: MainLayoutInput) { - return ( - <> - - - {children} - - - ); + return {children}; } diff --git a/src/src/common/core/components/generic/main-layout/styles.module.css b/src/src/common/core/components/generic/main-layout/styles.module.css index 10b0f7b..354c5c2 100644 --- a/src/src/common/core/components/generic/main-layout/styles.module.css +++ b/src/src/common/core/components/generic/main-layout/styles.module.css @@ -1,14 +1,4 @@ -.outer { - background-color: light-dark( - var(--mantine-color-gray-1), - var(--mantine-color-dark-7) - ); - height: 100%; - padding: 0; - width: 100%; -} - -.inner { +.box { align-items: center; display: flex; flex-direction: column; diff --git a/src/src/common/core/components/generic/page-layout/index.ts b/src/src/common/core/components/generic/page-layout/index.ts new file mode 100644 index 0000000..4127eda --- /dev/null +++ b/src/src/common/core/components/generic/page-layout/index.ts @@ -0,0 +1,2 @@ +export { PageLayout } from "./main"; +export type { PageLayoutInput } from "./types"; diff --git a/src/src/common/core/components/generic/page-layout/main.tsx b/src/src/common/core/components/generic/page-layout/main.tsx new file mode 100644 index 0000000..3bc0b6c --- /dev/null +++ b/src/src/common/core/components/generic/page-layout/main.tsx @@ -0,0 +1,15 @@ +import { Box } from "@mantine/core"; +import { Notifications } from "@mantine/notifications"; + +import type { PageLayoutInput } from "./types"; + +import classes from "./styles.module.css"; + +export function PageLayout({ children }: PageLayoutInput) { + return ( + <> + + {children} + + ); +} diff --git a/src/src/common/core/components/generic/page-layout/styles.module.css b/src/src/common/core/components/generic/page-layout/styles.module.css new file mode 100644 index 0000000..ab3dbb0 --- /dev/null +++ b/src/src/common/core/components/generic/page-layout/styles.module.css @@ -0,0 +1,9 @@ +.box { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-7) + ); + height: 100%; + padding: 0; + width: 100%; +} diff --git a/src/src/common/core/components/generic/page-layout/types.ts b/src/src/common/core/components/generic/page-layout/types.ts new file mode 100644 index 0000000..6a966f0 --- /dev/null +++ b/src/src/common/core/components/generic/page-layout/types.ts @@ -0,0 +1,3 @@ +import type { PropsWithChildren } from "react"; + +export type PageLayoutInput = PropsWithChildren; diff --git a/src/src/common/core/components/generic/safe-main-layout/main.tsx b/src/src/common/core/components/generic/safe-main-layout/main.tsx index 0ec3777..ca6e2df 100644 --- a/src/src/common/core/components/generic/safe-main-layout/main.tsx +++ b/src/src/common/core/components/generic/safe-main-layout/main.tsx @@ -3,9 +3,5 @@ import type { SafeMainLayoutInput } from "./types"; import classes from "./styles.module.css"; export function SafeMainLayout({ children }: SafeMainLayoutInput) { - return ( -
-
{children}
-
- ); + return
{children}
; } diff --git a/src/src/common/core/components/generic/safe-main-layout/styles.module.css b/src/src/common/core/components/generic/safe-main-layout/styles.module.css index b81a0b7..6582965 100644 --- a/src/src/common/core/components/generic/safe-main-layout/styles.module.css +++ b/src/src/common/core/components/generic/safe-main-layout/styles.module.css @@ -1,10 +1,4 @@ -.outer { - height: 100%; - padding: 0; - width: 100%; -} - -.inner { +.box { align-items: center; display: flex; flex-direction: column; diff --git a/src/src/common/core/components/generic/safe-page-layout/index.ts b/src/src/common/core/components/generic/safe-page-layout/index.ts new file mode 100644 index 0000000..4f46aa2 --- /dev/null +++ b/src/src/common/core/components/generic/safe-page-layout/index.ts @@ -0,0 +1,2 @@ +export { SafePageLayout } from "./main"; +export type { SafePageLayoutInput } from "./types"; diff --git a/src/src/common/core/components/generic/safe-page-layout/main.tsx b/src/src/common/core/components/generic/safe-page-layout/main.tsx new file mode 100644 index 0000000..6ccfdaa --- /dev/null +++ b/src/src/common/core/components/generic/safe-page-layout/main.tsx @@ -0,0 +1,7 @@ +import type { SafePageLayoutInput } from "./types"; + +import classes from "./styles.module.css"; + +export function SafePageLayout({ children }: SafePageLayoutInput) { + return
{children}
; +} diff --git a/src/src/common/core/components/generic/safe-page-layout/styles.module.css b/src/src/common/core/components/generic/safe-page-layout/styles.module.css new file mode 100644 index 0000000..cfe15c6 --- /dev/null +++ b/src/src/common/core/components/generic/safe-page-layout/styles.module.css @@ -0,0 +1,5 @@ +.box { + height: 100%; + padding: 0; + width: 100%; +} diff --git a/src/src/common/core/components/generic/safe-page-layout/types.ts b/src/src/common/core/components/generic/safe-page-layout/types.ts new file mode 100644 index 0000000..59d45ac --- /dev/null +++ b/src/src/common/core/components/generic/safe-page-layout/types.ts @@ -0,0 +1,3 @@ +import type { PropsWithChildren } from "react"; + +export type SafePageLayoutInput = PropsWithChildren; diff --git a/src/src/common/localization/locales/en.po.jinja b/src/src/common/localization/locales/en.po.jinja index 7241a22..a99fb2e 100644 --- a/src/src/common/localization/locales/en.po.jinja +++ b/src/src/common/localization/locales/en.po.jinja @@ -29,14 +29,20 @@ msgstr "Error" msgid "Error • {{ appname }}" msgstr "Error • {{ appname }}" -#: src/app/(root)/layout.tsx:34 +#: src/app/(root)/layout.tsx:{% if identity %}36{% else %}34{% endif %} msgid "{{ description }}" msgstr "{{ description }}" -#: src/app/(root)/(main)/(home)/page.tsx:20 -#: src/app/(root)/layout.tsx:40 +#: src/app/(root)/(main)/(home)/page.tsx:{% if identity and access %}21{% else %}20{% endif %} +#: src/app/(root)/layout.tsx:{% if identity %}42{% else %}40{% endif %} msgid "{{ appname }}" msgstr "{{ appname }}" +{%- if identity and access %} + +#: src/app/(root)/forbidden.tsx:11 +msgid "Forbidden • {{ appname }}" +msgstr "Forbidden • {{ appname }}" +{%- endif %} #: src/isomorphic/notifications/hooks/use-notifications/main.ts:51 msgid "Info" @@ -75,6 +81,18 @@ msgstr "Success" msgid "Warning" msgstr "Warning" -#: src/server/orpc/vars/procedures/vars/test/vars/validate/main.ts:27 +#: src/server/orpc/vars/procedures/vars/test/vars/validate/main.ts:{% if identity and access %}29{% else %}27{% endif %} msgid "Why did the chicken cross the road? To get to the other side." msgstr "Why did the chicken cross the road? To get to the other side." +{%- if identity and access %} + +#: src/isomorphic/access/components/forbidden-widget/main.tsx:16 +msgid "You are not allowed to access this page" +msgstr "You are not allowed to access this page" +{%- endif %} +{%- if identity %} + +#: src/isomorphic/identity/components/identity-provider/components/user-synchronizer/main.tsx:56 +msgid "You are not authenticated." +msgstr "You are not authenticated." +{%- endif %} diff --git a/src/src/common/localization/locales/pl.po.jinja b/src/src/common/localization/locales/pl.po.jinja index a00b154..fdabb47 100644 --- a/src/src/common/localization/locales/pl.po.jinja +++ b/src/src/common/localization/locales/pl.po.jinja @@ -29,14 +29,20 @@ msgstr "Błąd" msgid "Error • {{ appname }}" msgstr "Błąd • {{ appname }}" -#: src/app/(root)/layout.tsx:34 +#: src/app/(root)/layout.tsx:{% if identity %}36{% else %}34{% endif %} msgid "{{ description }}" msgstr "{{ description }}" -#: src/app/(root)/(main)/(home)/page.tsx:20 -#: src/app/(root)/layout.tsx:40 +#: src/app/(root)/(main)/(home)/page.tsx:{% if identity and access %}21{% else %}20{% endif %} +#: src/app/(root)/layout.tsx:{% if identity %}42{% else %}40{% endif %} msgid "{{ appname }}" msgstr "{{ appname }}" +{%- if identity and access %} + +#: src/app/(root)/forbidden.tsx:11 +msgid "Forbidden • {{ appname }}" +msgstr "Brak dostępu • {{ appname }}" +{%- endif %} #: src/isomorphic/notifications/hooks/use-notifications/main.ts:51 msgid "Info" @@ -75,6 +81,18 @@ msgstr "Sukces" msgid "Warning" msgstr "Ostrzeżenie" -#: src/server/orpc/vars/procedures/vars/test/vars/validate/main.ts:27 +#: src/server/orpc/vars/procedures/vars/test/vars/validate/main.ts:{% if identity and access %}29{% else %}27{% endif %} msgid "Why did the chicken cross the road? To get to the other side." msgstr "Dlaczego kura przeszła przez ulicę? Żeby dostać się na drugą stronę." +{%- if identity and access %} + +#: src/isomorphic/access/components/forbidden-widget/main.tsx:16 +msgid "You are not allowed to access this page" +msgstr "Nie masz dostępu do tej strony" +{%- endif %} +{%- if identity %} + +#: src/isomorphic/identity/components/identity-provider/components/user-synchronizer/main.tsx:56 +msgid "You are not authenticated." +msgstr "Nie jesteś uwierzytelniony." +{%- endif %} diff --git a/src/src/common/orpc/vars/bases/root/main.ts b/src/src/common/orpc/vars/bases/root/main.ts.jinja similarity index 81% rename from src/src/common/orpc/vars/bases/root/main.ts rename to src/src/common/orpc/vars/bases/root/main.ts.jinja index eee7cfc..3688581 100644 --- a/src/src/common/orpc/vars/bases/root/main.ts +++ b/src/src/common/orpc/vars/bases/root/main.ts.jinja @@ -4,6 +4,9 @@ import { Schemas } from "./schemas"; export const orpcContractRootBase = orpcContractBuilder.errors({ BAD_REQUEST: { data: Schemas.Errors.BadRequest }, CONFLICT: {}, + {%- if identity and access %} + FORBIDDEN: {}, + {%- endif %} INTERNAL_SERVER_ERROR: {}, NOT_FOUND: {}, }); diff --git a/src/src/common/orpc/vars/procedures/main.ts b/src/src/common/orpc/vars/procedures/main.ts.jinja similarity index 56% rename from src/src/common/orpc/vars/procedures/main.ts rename to src/src/common/orpc/vars/procedures/main.ts.jinja index 838259c..94bbf54 100644 --- a/src/src/common/orpc/vars/procedures/main.ts +++ b/src/src/common/orpc/vars/procedures/main.ts.jinja @@ -1,7 +1,13 @@ +{% if identity -%} +import { identity } from "./vars/identity"; +{% endif -%} import { localization } from "./vars/localization"; import { test } from "./vars/test"; export const orpcContractProcedures = { + {%- if identity %} + identity: identity, + {%- endif %} localization: localization, test: test, }; diff --git a/src/src/common/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/index.ts b/src/src/common/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/index.ts new file mode 100644 index 0000000..dd08ca7 --- /dev/null +++ b/src/src/common/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/index.ts @@ -0,0 +1 @@ +export { identity } from "./main"; diff --git a/src/src/common/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/main.ts b/src/src/common/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/main.ts new file mode 100644 index 0000000..5e964ec --- /dev/null +++ b/src/src/common/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/main.ts @@ -0,0 +1,5 @@ +import { getUser } from "./vars/get-user"; + +export const identity = { + getUser: getUser, +}; diff --git a/src/src/common/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/vars/get-user/index.ts b/src/src/common/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/vars/get-user/index.ts new file mode 100644 index 0000000..ce51a8d --- /dev/null +++ b/src/src/common/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/vars/get-user/index.ts @@ -0,0 +1 @@ +export { getUser } from "./main"; diff --git a/src/src/common/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/vars/get-user/main.ts b/src/src/common/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/vars/get-user/main.ts new file mode 100644 index 0000000..f2fcc8a --- /dev/null +++ b/src/src/common/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/vars/get-user/main.ts @@ -0,0 +1,6 @@ +import { orpcContractRootBase } from "../../../../../bases/root"; +import { Schemas } from "./schemas"; + +export const getUser = orpcContractRootBase + .input(Schemas.Input) + .output(Schemas.Output); diff --git a/src/src/common/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/vars/get-user/schemas.ts b/src/src/common/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/vars/get-user/schemas.ts new file mode 100644 index 0000000..54cb634 --- /dev/null +++ b/src/src/common/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/vars/get-user/schemas.ts @@ -0,0 +1,10 @@ +import * as z from "zod"; + +import { IdentitySchemas } from "../../../../../../../identity/schemas"; + +export const Schemas = { + Input: z.undefined(), + Output: z.object({ + user: IdentitySchemas.User.nullable(), + }), +}; diff --git a/src/src/common/{% if identity %}identity{% endif %}/schemas.ts b/src/src/common/{% if identity %}identity{% endif %}/schemas.ts new file mode 100644 index 0000000..deb3934 --- /dev/null +++ b/src/src/common/{% if identity %}identity{% endif %}/schemas.ts @@ -0,0 +1,7 @@ +import * as z from "zod"; + +export const IdentitySchemas = { + User: z.object({ + id: z.string(), + }), +}; diff --git a/src/src/common/{% if identity %}identity{% endif %}/types.ts b/src/src/common/{% if identity %}identity{% endif %}/types.ts new file mode 100644 index 0000000..38b3d4f --- /dev/null +++ b/src/src/common/{% if identity %}identity{% endif %}/types.ts @@ -0,0 +1,5 @@ +import type * as z from "zod"; + +import type { IdentitySchemas } from "./schemas"; + +export type User = z.infer; diff --git a/src/src/common/{% if identity and access %}access{% endif %}/lib/is-authenticated/index.ts b/src/src/common/{% if identity and access %}access{% endif %}/lib/is-authenticated/index.ts new file mode 100644 index 0000000..75e38c4 --- /dev/null +++ b/src/src/common/{% if identity and access %}access{% endif %}/lib/is-authenticated/index.ts @@ -0,0 +1 @@ +export { isAuthenticated } from "./main"; diff --git a/src/src/common/{% if identity and access %}access{% endif %}/lib/is-authenticated/main.ts b/src/src/common/{% if identity and access %}access{% endif %}/lib/is-authenticated/main.ts new file mode 100644 index 0000000..97d82f9 --- /dev/null +++ b/src/src/common/{% if identity and access %}access{% endif %}/lib/is-authenticated/main.ts @@ -0,0 +1,5 @@ +import type { User } from "../../../identity/types"; + +export function isAuthenticated(user: null | User): user is User { + return user !== null; +} diff --git a/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/components/user-synchronizer/index.ts b/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/components/user-synchronizer/index.ts new file mode 100644 index 0000000..30306f4 --- /dev/null +++ b/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/components/user-synchronizer/index.ts @@ -0,0 +1,2 @@ +export { UserSynchronizer } from "./main"; +export type { UserSynchronizerInput } from "./types"; diff --git a/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/components/user-synchronizer/main.tsx b/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/components/user-synchronizer/main.tsx new file mode 100644 index 0000000..2448c46 --- /dev/null +++ b/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/components/user-synchronizer/main.tsx @@ -0,0 +1,73 @@ +import { msg } from "@lingui/core/macro"; +import { ORPCError } from "@orpc/client"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; + +import type { UserSynchronizerInput } from "./types"; + +import { orpcClientSideQueryClient } from "../../../../../../client/orpc/vars/clients"; +import { useSafeContext } from "../../../../../generic/hooks/use-safe-context"; +import { useNotifications } from "../../../../../notifications/hooks/use-notifications"; +import { IdentityContext } from "../../../../contexts/identity"; + +export function UserSynchronizer({}: UserSynchronizerInput) { + const [unauthenticated, setUnauthenticated] = useState(false); + const [notification, setNotification] = useState(); + + const identity = useSafeContext(IdentityContext); + + const { notifications } = useNotifications(); + + const getUserQuery = useQuery( + orpcClientSideQueryClient.identity.getUser.queryOptions(), + ); + + useEffect(() => { + if (getUserQuery.data === undefined) return; + identity.user = getUserQuery.data.user; + }, [getUserQuery.data, identity]); + + useEffect(() => { + const error = getUserQuery.error; + + if ( + unauthenticated || + !(error instanceof ORPCError) || + error.code !== "UNAUTHORIZED" + ) + return; + + setUnauthenticated(true); + }, [unauthenticated, getUserQuery.error]); + + useEffect(() => { + const error = getUserQuery.error; + + if (!unauthenticated || error !== null) return; + + setUnauthenticated(false); + }, [unauthenticated, getUserQuery.error]); + + useEffect(() => { + if (!unauthenticated) return; + + const id = notifications.error({ + autoClose: false, + message: msg({ message: "You are not authenticated." }), + withCloseButton: false, + }); + + setNotification(id); + + return () => notifications.remove(id); + }, [unauthenticated, notifications.error, notifications.remove]); + + useEffect(() => { + if (unauthenticated || !notification) return; + + notifications.remove(notification); + setNotification(undefined); + }, [unauthenticated, notification, notifications.remove]); + + return null; +} diff --git a/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/components/user-synchronizer/types.ts b/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/components/user-synchronizer/types.ts new file mode 100644 index 0000000..3b41e2a --- /dev/null +++ b/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/components/user-synchronizer/types.ts @@ -0,0 +1 @@ +export type UserSynchronizerInput = object; diff --git a/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/index.ts b/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/index.ts new file mode 100644 index 0000000..000e1f8 --- /dev/null +++ b/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/index.ts @@ -0,0 +1,2 @@ +export { IdentityProvider } from "./main"; +export type { IdentityProviderInput } from "./types"; diff --git a/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/main.tsx b/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/main.tsx new file mode 100644 index 0000000..9a706a5 --- /dev/null +++ b/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/main.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useState } from "react"; + +import type { IdentityProviderInput } from "./types"; + +import { IdentityContext } from "../../contexts/identity"; +import { UserSynchronizer } from "./components/user-synchronizer"; +import { createInitialIdentityContextValue } from "./utils"; + +export function IdentityProvider({ children, user }: IdentityProviderInput) { + const [value] = useState(() => createInitialIdentityContextValue(user)); + + return ( + + + {children} + + ); +} diff --git a/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/types.ts b/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/types.ts new file mode 100644 index 0000000..5ed842e --- /dev/null +++ b/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/types.ts @@ -0,0 +1,7 @@ +import type { PropsWithChildren } from "react"; + +import type { User } from "../../../../common/identity/types"; + +export type IdentityProviderInput = PropsWithChildren<{ + user: null | User; +}>; diff --git a/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/utils.ts b/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/utils.ts new file mode 100644 index 0000000..ba40f46 --- /dev/null +++ b/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-provider/utils.ts @@ -0,0 +1,8 @@ +import { proxy } from "valtio"; + +import type { User } from "../../../../common/identity/types"; +import type { IdentityContextValue } from "../../contexts/identity"; + +export function createInitialIdentityContextValue(user: null | User) { + return proxy({ user: user } satisfies IdentityContextValue); +} diff --git a/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-widget/index.ts b/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-widget/index.ts new file mode 100644 index 0000000..d88bdea --- /dev/null +++ b/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-widget/index.ts @@ -0,0 +1,2 @@ +export { IdentityWidget } from "./main"; +export type { IdentityWidgetInput } from "./types"; diff --git a/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-widget/main.tsx b/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-widget/main.tsx new file mode 100644 index 0000000..318c43c --- /dev/null +++ b/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-widget/main.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { ActionIcon, Affix, Group, Menu, Stack, Text } from "@mantine/core"; +import { MdPerson } from "react-icons/md"; + +import type { IdentityWidgetInput } from "./types"; + +import { useIdentity } from "../../hooks/use-identity"; + +export function IdentityWidget({}: IdentityWidgetInput) { + const { identity } = useIdentity(); + + if (identity.user === null) return null; + + return ( + + + + + + + + + + + + {identity.user.id} + + + + + + + ); +} diff --git a/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-widget/types.ts b/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-widget/types.ts new file mode 100644 index 0000000..cf06988 --- /dev/null +++ b/src/src/isomorphic/{% if identity %}identity{% endif %}/components/identity-widget/types.ts @@ -0,0 +1 @@ +export type IdentityWidgetInput = object; diff --git a/src/src/isomorphic/{% if identity %}identity{% endif %}/contexts/identity/index.ts b/src/src/isomorphic/{% if identity %}identity{% endif %}/contexts/identity/index.ts new file mode 100644 index 0000000..0f16ed0 --- /dev/null +++ b/src/src/isomorphic/{% if identity %}identity{% endif %}/contexts/identity/index.ts @@ -0,0 +1,4 @@ +import "client-only"; + +export { IdentityContext } from "./main"; +export type { IdentityContextValue } from "./types"; diff --git a/src/src/isomorphic/{% if identity %}identity{% endif %}/contexts/identity/main.ts b/src/src/isomorphic/{% if identity %}identity{% endif %}/contexts/identity/main.ts new file mode 100644 index 0000000..43b4363 --- /dev/null +++ b/src/src/isomorphic/{% if identity %}identity{% endif %}/contexts/identity/main.ts @@ -0,0 +1,7 @@ +import { createContext } from "react"; + +import type { IdentityContextValue } from "./types"; + +export const IdentityContext = createContext( + undefined, +); diff --git a/src/src/isomorphic/{% if identity %}identity{% endif %}/contexts/identity/types.ts b/src/src/isomorphic/{% if identity %}identity{% endif %}/contexts/identity/types.ts new file mode 100644 index 0000000..abf75c6 --- /dev/null +++ b/src/src/isomorphic/{% if identity %}identity{% endif %}/contexts/identity/types.ts @@ -0,0 +1,5 @@ +import type { User } from "../../../../common/identity/types"; + +export type IdentityContextValue = { + user: null | User; +}; diff --git a/src/src/isomorphic/{% if identity %}identity{% endif %}/hooks/use-identity/index.ts b/src/src/isomorphic/{% if identity %}identity{% endif %}/hooks/use-identity/index.ts new file mode 100644 index 0000000..3d49343 --- /dev/null +++ b/src/src/isomorphic/{% if identity %}identity{% endif %}/hooks/use-identity/index.ts @@ -0,0 +1,4 @@ +import "client-only"; + +export { useIdentity } from "./main"; +export type { Identity, UseIdentityInput, UseIdentityOutput } from "./types"; diff --git a/src/src/isomorphic/{% if identity %}identity{% endif %}/hooks/use-identity/main.ts b/src/src/isomorphic/{% if identity %}identity{% endif %}/hooks/use-identity/main.ts new file mode 100644 index 0000000..ef9ad27 --- /dev/null +++ b/src/src/isomorphic/{% if identity %}identity{% endif %}/hooks/use-identity/main.ts @@ -0,0 +1,16 @@ +import { useMemo } from "react"; +import { useSnapshot } from "valtio"; + +import type { UseIdentityInput, UseIdentityOutput } from "./types"; + +import { useSafeContext } from "../../../generic/hooks/use-safe-context"; +import { IdentityContext } from "../../contexts/identity"; + +export function useIdentity({}: UseIdentityInput = {}): UseIdentityOutput { + const current = useSafeContext(IdentityContext); + const snapshot = useSnapshot(current); + + const identity = useMemo(() => ({ user: snapshot.user }), [snapshot.user]); + + return useMemo(() => ({ identity }), [identity]); +} diff --git a/src/src/isomorphic/{% if identity %}identity{% endif %}/hooks/use-identity/types.ts b/src/src/isomorphic/{% if identity %}identity{% endif %}/hooks/use-identity/types.ts new file mode 100644 index 0000000..2b04b96 --- /dev/null +++ b/src/src/isomorphic/{% if identity %}identity{% endif %}/hooks/use-identity/types.ts @@ -0,0 +1,11 @@ +import type { User } from "../../../../common/identity/types"; + +export type Identity = { + user: null | User; +}; + +export type UseIdentityInput = object; + +export type UseIdentityOutput = { + identity: Identity; +}; diff --git a/src/src/isomorphic/{% if identity and access %}access{% endif %}/components/forbidden-widget/index.ts b/src/src/isomorphic/{% if identity and access %}access{% endif %}/components/forbidden-widget/index.ts new file mode 100644 index 0000000..b405129 --- /dev/null +++ b/src/src/isomorphic/{% if identity and access %}access{% endif %}/components/forbidden-widget/index.ts @@ -0,0 +1,2 @@ +export { ForbiddenWidget } from "./main"; +export type { ForbiddenWidgetInput } from "./types"; diff --git a/src/src/isomorphic/{% if identity and access %}access{% endif %}/components/forbidden-widget/main.tsx b/src/src/isomorphic/{% if identity and access %}access{% endif %}/components/forbidden-widget/main.tsx new file mode 100644 index 0000000..d016c76 --- /dev/null +++ b/src/src/isomorphic/{% if identity and access %}access{% endif %}/components/forbidden-widget/main.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { msg } from "@lingui/core/macro"; +import { Title } from "@mantine/core"; + +import type { ForbiddenWidgetInput } from "./types"; + +import { useLocalization } from "../../../localization/hooks/use-localization"; + +export function ForbiddenWidget({ message }: ForbiddenWidgetInput) { + const { localization } = useLocalization(); + + return ( + + {localization.localize( + message ?? msg({ message: "You are not allowed to access this page" }), + )} + + ); +} diff --git a/src/src/isomorphic/{% if identity and access %}access{% endif %}/components/forbidden-widget/types.ts b/src/src/isomorphic/{% if identity and access %}access{% endif %}/components/forbidden-widget/types.ts new file mode 100644 index 0000000..6b652fb --- /dev/null +++ b/src/src/isomorphic/{% if identity and access %}access{% endif %}/components/forbidden-widget/types.ts @@ -0,0 +1,5 @@ +import type { MessageDescriptor } from "@lingui/core"; + +export type ForbiddenWidgetInput = { + message?: MessageDescriptor; +}; diff --git a/src/src/isomorphic/{% if identity and access %}access{% endif %}/hooks/use-authenticated/index.ts b/src/src/isomorphic/{% if identity and access %}access{% endif %}/hooks/use-authenticated/index.ts new file mode 100644 index 0000000..3449a45 --- /dev/null +++ b/src/src/isomorphic/{% if identity and access %}access{% endif %}/hooks/use-authenticated/index.ts @@ -0,0 +1,4 @@ +import "client-only"; + +export { useAuthenticated } from "./main"; +export type { UseAuthenticatedInput, UseAuthenticatedOutput } from "./types"; diff --git a/src/src/isomorphic/{% if identity and access %}access{% endif %}/hooks/use-authenticated/main.ts b/src/src/isomorphic/{% if identity and access %}access{% endif %}/hooks/use-authenticated/main.ts new file mode 100644 index 0000000..7ef339b --- /dev/null +++ b/src/src/isomorphic/{% if identity and access %}access{% endif %}/hooks/use-authenticated/main.ts @@ -0,0 +1,13 @@ +import { forbidden } from "next/navigation"; + +import type { UseAuthenticatedInput, UseAuthenticatedOutput } from "./types"; + +import { isAuthenticated } from "../../../../common/access/lib/is-authenticated"; + +export function useAuthenticated({ + user, +}: UseAuthenticatedInput): UseAuthenticatedOutput { + if (!isAuthenticated(user)) forbidden(); + + return { user: user }; +} diff --git a/src/src/isomorphic/{% if identity and access %}access{% endif %}/hooks/use-authenticated/types.ts b/src/src/isomorphic/{% if identity and access %}access{% endif %}/hooks/use-authenticated/types.ts new file mode 100644 index 0000000..c99c5ac --- /dev/null +++ b/src/src/isomorphic/{% if identity and access %}access{% endif %}/hooks/use-authenticated/types.ts @@ -0,0 +1,9 @@ +import type { User } from "../../../../common/identity/types"; + +export type UseAuthenticatedInput = { + user: null | User; +}; + +export type UseAuthenticatedOutput = { + user: User; +}; diff --git a/src/src/server/config/schemas.ts.jinja b/src/src/server/config/schemas.ts.jinja index 690e66e..85eee8e 100644 --- a/src/src/server/config/schemas.ts.jinja +++ b/src/src/server/config/schemas.ts.jinja @@ -17,6 +17,50 @@ export const ConfigSchemas = { }) .prefault({}), debug: z.stringbool().default(true), + {%- if identity %} + identity: z + .object({ + users: z + .object({ + debug: z + .object({ + id: z.string().default("debug"), + traits: z + .object({ + locales: z + .object({ + preferred: z + .string() + .nullish() + .transform((value) => value ?? undefined), + }) + .optional(), + names: z + .object({ + display: z.string().default("Debug User"), + }) + .prefault({}), + pictures: z + .object({ + profile: z + .object({ + url: z + .url() + .nullish() + .transform((value) => value ?? undefined), + }) + .optional(), + }) + .optional(), + }) + .prefault({}), + }) + .prefault({}), + }) + .prefault({}), + }) + .prefault({}), + {%- endif %} server: z .object({ host: z.string().default("0.0.0.0"), diff --git a/src/src/server/orpc/vars/middleware/{% if identity %}user{% endif %}/index.ts b/src/src/server/orpc/vars/middleware/{% if identity %}user{% endif %}/index.ts new file mode 100644 index 0000000..680f13e --- /dev/null +++ b/src/src/server/orpc/vars/middleware/{% if identity %}user{% endif %}/index.ts @@ -0,0 +1,4 @@ +import "server-only"; + +export { userMiddleware } from "./main"; +export type { UserMiddlewareOutputContext } from "./types"; diff --git a/src/src/server/orpc/vars/middleware/{% if identity %}user{% endif %}/main.ts b/src/src/server/orpc/vars/middleware/{% if identity %}user{% endif %}/main.ts new file mode 100644 index 0000000..c7a91a6 --- /dev/null +++ b/src/src/server/orpc/vars/middleware/{% if identity %}user{% endif %}/main.ts @@ -0,0 +1,37 @@ +import type { UserMiddlewareOutputContext } from "./types"; + +import { getUser } from "../../../../identity/lib/get-user"; +import { state } from "../../../../state/vars/state"; +import { headersMiddleware } from "../headers"; +import { isExecuted } from "./utils"; + +export const userMiddleware = headersMiddleware.concat( + async ({ context, next }) => { + if (isExecuted(context)) + return next({ + context: { + userMiddleware: { + executed: context.userMiddleware.executed, + user: context.userMiddleware.user, + }, + } satisfies UserMiddlewareOutputContext as UserMiddlewareOutputContext, + }); + + const headers = context.headersMiddleware.headers; + + const { user: resolvedUser } = await getUser({ headers: headers }); + const fallbackUser = state.current.config.debug + ? state.current.config.identity.users.debug + : null; + const user = resolvedUser ?? fallbackUser; + + return next({ + context: { + userMiddleware: { + executed: true, + user: user, + }, + } satisfies UserMiddlewareOutputContext as UserMiddlewareOutputContext, + }); + }, +); diff --git a/src/src/server/orpc/vars/middleware/{% if identity %}user{% endif %}/types.ts b/src/src/server/orpc/vars/middleware/{% if identity %}user{% endif %}/types.ts new file mode 100644 index 0000000..5a3620c --- /dev/null +++ b/src/src/server/orpc/vars/middleware/{% if identity %}user{% endif %}/types.ts @@ -0,0 +1,9 @@ +import type { User } from "../../../../../common/identity/types"; +import type { MiddlewareOutputContext } from "../../../types/middleware"; + +export type UserMiddlewareOutputContext = MiddlewareOutputContext< + "user", + { + user: null | User; + } +>; diff --git a/src/src/server/orpc/vars/middleware/{% if identity %}user{% endif %}/utils.ts b/src/src/server/orpc/vars/middleware/{% if identity %}user{% endif %}/utils.ts new file mode 100644 index 0000000..1900069 --- /dev/null +++ b/src/src/server/orpc/vars/middleware/{% if identity %}user{% endif %}/utils.ts @@ -0,0 +1,9 @@ +import type { UserMiddlewareOutputContext } from "./types"; + +import { isMiddlewareExecuted } from "../../../lib/middleware/is-middleware-executed"; + +export function isExecuted( + context: unknown, +): context is UserMiddlewareOutputContext { + return isMiddlewareExecuted(context, "user"); +} diff --git a/src/src/server/orpc/vars/middleware/{% if identity and access %}authenticated{% endif %}/index.ts b/src/src/server/orpc/vars/middleware/{% if identity and access %}authenticated{% endif %}/index.ts new file mode 100644 index 0000000..edf8b51 --- /dev/null +++ b/src/src/server/orpc/vars/middleware/{% if identity and access %}authenticated{% endif %}/index.ts @@ -0,0 +1,4 @@ +import "server-only"; + +export { authenticatedMiddleware } from "./main"; +export type { AuthenticatedMiddlewareOutputContext } from "./types"; diff --git a/src/src/server/orpc/vars/middleware/{% if identity and access %}authenticated{% endif %}/main.ts b/src/src/server/orpc/vars/middleware/{% if identity and access %}authenticated{% endif %}/main.ts new file mode 100644 index 0000000..685cbd2 --- /dev/null +++ b/src/src/server/orpc/vars/middleware/{% if identity and access %}authenticated{% endif %}/main.ts @@ -0,0 +1,33 @@ +import type { ORPCDefinedErrors } from "../../../types/inferred"; +import type { AuthenticatedMiddlewareOutputContext } from "./types"; + +import { isAuthenticated } from "../../../../../common/access/lib/is-authenticated"; +import { userMiddleware } from "../user"; +import { isExecuted } from "./utils"; + +export const authenticatedMiddleware = userMiddleware.concat( + async ({ context, errors, next }) => { + if (isExecuted(context)) + return next({ + context: { + authenticatedMiddleware: { + executed: context.authenticatedMiddleware.executed, + user: context.authenticatedMiddleware.user, + }, + } satisfies AuthenticatedMiddlewareOutputContext as AuthenticatedMiddlewareOutputContext, + }); + + const user = context.userMiddleware.user; + + if (!isAuthenticated(user)) throw (errors as ORPCDefinedErrors).FORBIDDEN(); + + return next({ + context: { + authenticatedMiddleware: { + executed: true, + user: user, + }, + } satisfies AuthenticatedMiddlewareOutputContext as AuthenticatedMiddlewareOutputContext, + }); + }, +); diff --git a/src/src/server/orpc/vars/middleware/{% if identity and access %}authenticated{% endif %}/types.ts b/src/src/server/orpc/vars/middleware/{% if identity and access %}authenticated{% endif %}/types.ts new file mode 100644 index 0000000..bd2f8ea --- /dev/null +++ b/src/src/server/orpc/vars/middleware/{% if identity and access %}authenticated{% endif %}/types.ts @@ -0,0 +1,9 @@ +import type { User } from "../../../../../common/identity/types"; +import type { MiddlewareOutputContext } from "../../../types/middleware"; + +export type AuthenticatedMiddlewareOutputContext = MiddlewareOutputContext< + "authenticated", + { + user: User; + } +>; diff --git a/src/src/server/orpc/vars/middleware/{% if identity and access %}authenticated{% endif %}/utils.ts b/src/src/server/orpc/vars/middleware/{% if identity and access %}authenticated{% endif %}/utils.ts new file mode 100644 index 0000000..6d27af2 --- /dev/null +++ b/src/src/server/orpc/vars/middleware/{% if identity and access %}authenticated{% endif %}/utils.ts @@ -0,0 +1,9 @@ +import type { AuthenticatedMiddlewareOutputContext } from "./types"; + +import { isMiddlewareExecuted } from "../../../lib/middleware/is-middleware-executed"; + +export function isExecuted( + context: unknown, +): context is AuthenticatedMiddlewareOutputContext { + return isMiddlewareExecuted(context, "authenticated"); +} diff --git a/src/src/server/orpc/vars/procedures/main.ts b/src/src/server/orpc/vars/procedures/main.ts.jinja similarity index 55% rename from src/src/server/orpc/vars/procedures/main.ts rename to src/src/server/orpc/vars/procedures/main.ts.jinja index acc042a..ccd7321 100644 --- a/src/src/server/orpc/vars/procedures/main.ts +++ b/src/src/server/orpc/vars/procedures/main.ts.jinja @@ -1,7 +1,13 @@ +{% if identity -%} +import { identity } from "./vars/identity"; +{% endif -%} import { localization } from "./vars/localization"; import { test } from "./vars/test"; export const orpcProcedures = { + {%- if identity %} + identity: identity, + {%- endif %} localization: localization, test: test, }; diff --git a/src/src/server/orpc/vars/procedures/vars/test/vars/validate/main.ts b/src/src/server/orpc/vars/procedures/vars/test/vars/validate/main.ts.jinja similarity index 84% rename from src/src/server/orpc/vars/procedures/vars/test/vars/validate/main.ts rename to src/src/server/orpc/vars/procedures/vars/test/vars/validate/main.ts.jinja index 2a8a63c..2e00c75 100644 --- a/src/src/server/orpc/vars/procedures/vars/test/vars/validate/main.ts +++ b/src/src/server/orpc/vars/procedures/vars/test/vars/validate/main.ts.jinja @@ -3,9 +3,15 @@ import { msg } from "@lingui/core/macro"; import { getLocalization } from "../../../../../../../localization/lib/get-localization"; import { state } from "../../../../../../../state/vars/state"; import { orpcServerRootBase } from "../../../../../bases/root"; +{%- if identity and access %} +import { authenticatedMiddleware } from "../../../../../middleware/authenticated"; +{%- endif %} import { localeMiddleware } from "../../../../../middleware/locale"; export const validate = orpcServerRootBase.test.validate + {%- if identity and access %} + .use(authenticatedMiddleware) + {%- endif %} .use(localeMiddleware) .handler(async ({ context, errors, input }) => { const { localization } = getLocalization({ diff --git a/src/src/server/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/index.ts b/src/src/server/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/index.ts new file mode 100644 index 0000000..dd08ca7 --- /dev/null +++ b/src/src/server/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/index.ts @@ -0,0 +1 @@ +export { identity } from "./main"; diff --git a/src/src/server/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/main.ts b/src/src/server/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/main.ts new file mode 100644 index 0000000..5e964ec --- /dev/null +++ b/src/src/server/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/main.ts @@ -0,0 +1,5 @@ +import { getUser } from "./vars/get-user"; + +export const identity = { + getUser: getUser, +}; diff --git a/src/src/server/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/vars/get-user/index.ts b/src/src/server/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/vars/get-user/index.ts new file mode 100644 index 0000000..ce51a8d --- /dev/null +++ b/src/src/server/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/vars/get-user/index.ts @@ -0,0 +1 @@ +export { getUser } from "./main"; diff --git a/src/src/server/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/vars/get-user/main.ts b/src/src/server/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/vars/get-user/main.ts new file mode 100644 index 0000000..06631d0 --- /dev/null +++ b/src/src/server/orpc/vars/procedures/vars/{% if identity %}identity{% endif %}/vars/get-user/main.ts @@ -0,0 +1,8 @@ +import { orpcServerRootBase } from "../../../../../bases/root"; +import { userMiddleware } from "../../../../../middleware/user"; + +export const getUser = orpcServerRootBase.identity.getUser + .use(userMiddleware) + .handler(async ({ context }) => ({ + user: context.userMiddleware.user, + })); diff --git a/src/src/server/{% if identity %}identity{% endif %}/lib/get-identity/index.ts b/src/src/server/{% if identity %}identity{% endif %}/lib/get-identity/index.ts new file mode 100644 index 0000000..919a17e --- /dev/null +++ b/src/src/server/{% if identity %}identity{% endif %}/lib/get-identity/index.ts @@ -0,0 +1,4 @@ +import "server-only"; + +export { getIdentity } from "./main"; +export type { GetIdentityInput, GetIdentityOutput, Identity } from "./types"; diff --git a/src/src/server/{% if identity %}identity{% endif %}/lib/get-identity/main.ts b/src/src/server/{% if identity %}identity{% endif %}/lib/get-identity/main.ts new file mode 100644 index 0000000..7920f52 --- /dev/null +++ b/src/src/server/{% if identity %}identity{% endif %}/lib/get-identity/main.ts @@ -0,0 +1,17 @@ +import type { GetIdentityInput, GetIdentityOutput } from "./types"; + +import { orpcServerSideQueryClient } from "../../../orpc/vars/clients"; +import { getQueryClient } from "../../../query/lib/get-query-client"; + +export async function getIdentity({ + queryClient: inputQueryClient, +}: GetIdentityInput = {}): Promise { + const queryClient = inputQueryClient ?? getQueryClient().queryClient; + const { user } = await queryClient.fetchQuery( + orpcServerSideQueryClient.identity.getUser.queryOptions(), + ); + + const identity = { user: user }; + + return { identity: identity, queryClient: queryClient }; +} diff --git a/src/src/server/{% if identity %}identity{% endif %}/lib/get-identity/types.ts b/src/src/server/{% if identity %}identity{% endif %}/lib/get-identity/types.ts new file mode 100644 index 0000000..47fe4ef --- /dev/null +++ b/src/src/server/{% if identity %}identity{% endif %}/lib/get-identity/types.ts @@ -0,0 +1,16 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import type { User } from "../../../../common/identity/types"; + +export type Identity = { + user: null | User; +}; + +export type GetIdentityInput = { + queryClient?: QueryClient; +}; + +export type GetIdentityOutput = { + identity: Identity; + queryClient: QueryClient; +}; diff --git a/src/src/server/{% if identity %}identity{% endif %}/lib/get-user/index.ts b/src/src/server/{% if identity %}identity{% endif %}/lib/get-user/index.ts new file mode 100644 index 0000000..f6236c0 --- /dev/null +++ b/src/src/server/{% if identity %}identity{% endif %}/lib/get-user/index.ts @@ -0,0 +1,4 @@ +import "server-only"; + +export { getUser } from "./main"; +export type { GetUserInput, GetUserOutput } from "./types"; diff --git a/src/src/server/{% if identity %}identity{% endif %}/lib/get-user/main.ts b/src/src/server/{% if identity %}identity{% endif %}/lib/get-user/main.ts new file mode 100644 index 0000000..b94a08f --- /dev/null +++ b/src/src/server/{% if identity %}identity{% endif %}/lib/get-user/main.ts @@ -0,0 +1,13 @@ +import type { GetUserInput, GetUserOutput } from "./types"; + +import { Schemas } from "./schemas"; + +export async function getUser({ + headers, +}: GetUserInput): Promise { + const id = await Schemas.Id.safeParseAsync(headers.get("X-User-ID")); + + const user = id.success ? { id: id.data } : null; + + return { user: user }; +} diff --git a/src/src/server/{% if identity %}identity{% endif %}/lib/get-user/schemas.ts b/src/src/server/{% if identity %}identity{% endif %}/lib/get-user/schemas.ts new file mode 100644 index 0000000..f3a0d48 --- /dev/null +++ b/src/src/server/{% if identity %}identity{% endif %}/lib/get-user/schemas.ts @@ -0,0 +1,5 @@ +import { IdentitySchemas } from "../../../../common/identity/schemas"; + +export const Schemas = { + Id: IdentitySchemas.User.shape.id, +}; diff --git a/src/src/server/{% if identity %}identity{% endif %}/lib/get-user/types.ts b/src/src/server/{% if identity %}identity{% endif %}/lib/get-user/types.ts new file mode 100644 index 0000000..1c73d70 --- /dev/null +++ b/src/src/server/{% if identity %}identity{% endif %}/lib/get-user/types.ts @@ -0,0 +1,9 @@ +import type { User } from "../../../../common/identity/types"; + +export type GetUserInput = { + headers: Headers; +}; + +export type GetUserOutput = { + user: null | User; +}; diff --git a/src/src/server/{% if identity and access %}access{% endif %}/components/authenticated/components/reactive-authenticated/index.ts b/src/src/server/{% if identity and access %}access{% endif %}/components/authenticated/components/reactive-authenticated/index.ts new file mode 100644 index 0000000..9bae67d --- /dev/null +++ b/src/src/server/{% if identity and access %}access{% endif %}/components/authenticated/components/reactive-authenticated/index.ts @@ -0,0 +1,2 @@ +export { ReactiveAuthenticated } from "./main"; +export type { ReactiveAuthenticatedInput } from "./types"; diff --git a/src/src/server/{% if identity and access %}access{% endif %}/components/authenticated/components/reactive-authenticated/main.tsx b/src/src/server/{% if identity and access %}access{% endif %}/components/authenticated/components/reactive-authenticated/main.tsx new file mode 100644 index 0000000..48c5907 --- /dev/null +++ b/src/src/server/{% if identity and access %}access{% endif %}/components/authenticated/components/reactive-authenticated/main.tsx @@ -0,0 +1,15 @@ +"use client"; + +import type { ReactiveAuthenticatedInput } from "./types"; + +import { useAuthenticated } from "../../../../../../isomorphic/access/hooks/use-authenticated"; +import { useIdentity } from "../../../../../../isomorphic/identity/hooks/use-identity"; + +export function ReactiveAuthenticated({ + children, +}: ReactiveAuthenticatedInput) { + const { identity } = useIdentity(); + useAuthenticated({ user: identity.user }); + + return children; +} diff --git a/src/src/server/{% if identity and access %}access{% endif %}/components/authenticated/components/reactive-authenticated/types.ts b/src/src/server/{% if identity and access %}access{% endif %}/components/authenticated/components/reactive-authenticated/types.ts new file mode 100644 index 0000000..13d1f25 --- /dev/null +++ b/src/src/server/{% if identity and access %}access{% endif %}/components/authenticated/components/reactive-authenticated/types.ts @@ -0,0 +1,3 @@ +import type { PropsWithChildren } from "react"; + +export type ReactiveAuthenticatedInput = PropsWithChildren; diff --git a/src/src/server/{% if identity and access %}access{% endif %}/components/authenticated/index.ts b/src/src/server/{% if identity and access %}access{% endif %}/components/authenticated/index.ts new file mode 100644 index 0000000..decb0bd --- /dev/null +++ b/src/src/server/{% if identity and access %}access{% endif %}/components/authenticated/index.ts @@ -0,0 +1,4 @@ +import "server-only"; + +export { Authenticated } from "./main"; +export type { AuthenticatedInput } from "./types"; diff --git a/src/src/server/{% if identity and access %}access{% endif %}/components/authenticated/main.tsx b/src/src/server/{% if identity and access %}access{% endif %}/components/authenticated/main.tsx new file mode 100644 index 0000000..8a2a556 --- /dev/null +++ b/src/src/server/{% if identity and access %}access{% endif %}/components/authenticated/main.tsx @@ -0,0 +1,15 @@ +import { forbidden } from "next/navigation"; + +import type { AuthenticatedInput } from "./types"; + +import { isAuthenticated } from "../../../../common/access/lib/is-authenticated"; +import { getIdentity } from "../../../identity/lib/get-identity"; +import { ReactiveAuthenticated } from "./components/reactive-authenticated"; + +export async function Authenticated({ children }: AuthenticatedInput) { + const { identity } = await getIdentity(); + + if (!isAuthenticated(identity.user)) forbidden(); + + return {children}; +} diff --git a/src/src/server/{% if identity and access %}access{% endif %}/components/authenticated/types.ts b/src/src/server/{% if identity and access %}access{% endif %}/components/authenticated/types.ts new file mode 100644 index 0000000..87e21b9 --- /dev/null +++ b/src/src/server/{% if identity and access %}access{% endif %}/components/authenticated/types.ts @@ -0,0 +1,3 @@ +import type { PropsWithChildren } from "react"; + +export type AuthenticatedInput = PropsWithChildren; diff --git a/src/{% if docs %}docs{% endif %}/docs/02-Configuration.md.jinja b/src/{% if docs %}docs{% endif %}/docs/02-Configuration.md.jinja index a4fa7d8..21aa45a 100644 --- a/src/{% if docs %}docs{% endif %}/docs/02-Configuration.md.jinja +++ b/src/{% if docs %}docs{% endif %}/docs/02-Configuration.md.jinja @@ -22,6 +22,11 @@ You can configure the app at runtime using various environment variables: - `{{ envprefix }}__DEBUG` - enable debug mode (default: `true`) +{%- if identity %} +- `{{ envprefix }}__IDENTITY__USERS__DEBUG__ID` - + identifier of the debug user + (default: `debug`) +{%- endif %} - `{{ envprefix }}__SERVER__HOST` - host to run the server on (default: `0.0.0.0`) diff --git a/tests/test_docs.py b/tests/test_docs.py index e30fd79..b95abc2 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -20,6 +20,8 @@ def data() -> dict[str, str]: "envprefix": "FOO", "port": "3000", "keyprefix": "foo", + "identity": "true", + "access": "true", "docs": "true", "docsurl": "https://foo.github.io/foo", "releases": "false", diff --git a/tests/test_lint.py b/tests/test_lint.py index d843b42..f52c94f 100644 --- a/tests/test_lint.py +++ b/tests/test_lint.py @@ -20,6 +20,8 @@ def data() -> dict[str, str]: "envprefix": "FOO", "port": "3000", "keyprefix": "foo", + "identity": "true", + "access": "true", "docs": "true", "docsurl": "https://foo.github.io/foo", "releases": "true",