@starci/hfs 4.2.13 → 4.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +7 -3
- package/emit/db-types.mjs +2 -1
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +200 -8
- package/runtime/knowledge/hfs/fe-stack.yaml +221 -0
- package/runtime/knowledge/hfs/rules.yaml +50 -47
- package/runtime/knowledge/hfs/slots.yaml +307 -179
- package/runtime/knowledge/patterns/be/event-bus.yaml +15 -0
- package/runtime/knowledge/patterns/be/jobs.yaml +10 -0
- package/runtime/knowledge/patterns/be/projections.yaml +10 -0
- package/runtime/knowledge/patterns/be/queues.yaml +15 -0
- package/runtime/knowledge/patterns/be/reactors.yaml +8 -0
- package/runtime/knowledge/patterns/be/realtime.yaml +17 -0
- package/runtime/knowledge/patterns/be/saga.yaml +13 -0
- package/runtime/knowledge/patterns/fe/folder.yaml +24 -18
- package/runtime/knowledge/sonar-gate.yaml +10 -2
- package/runtime/modules/kernel/failure-codes.yaml +40 -60
- package/runtime/modules/kernel/removed-vocabulary.yaml +43 -0
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +0 -2
- package/runtime/scripts/hfs/architecture/config.mjs +2 -1
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +6 -2
- package/runtime/scripts/hfs/architecture/error-masked.mjs +10 -1
- package/runtime/scripts/hfs/architecture/frontend.mjs +10 -3
- package/runtime/scripts/hfs/architecture/hfs.mjs +6 -3
- package/runtime/scripts/hfs/architecture/index.mjs +2 -0
- package/runtime/scripts/hfs/architecture/owners.mjs +19 -16
- package/runtime/scripts/hfs/architecture/route-files-thin.mjs +4 -1
- package/runtime/scripts/hfs/architecture/server-action.mjs +75 -20
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +2 -2
- package/runtime/scripts/hfs/architecture/supabase.mjs +36 -210
- package/runtime/scripts/hfs/architecture/tiers.mjs +1 -33
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +110 -54
- package/runtime/scripts/hfs/architecture/type-context.mjs +4 -1
- package/runtime/scripts/hfs/check.mjs +10 -6
- package/runtime/scripts/hfs/edition-slots.mjs +13 -4
- package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
- package/runtime/scripts/hfs/rule-catalog.mjs +21 -1
- package/runtime/scripts/hfs/rules/database-constants.mjs +4 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +12 -7
- package/runtime/scripts/hfs/rules/database.mjs +4 -3
- package/runtime/scripts/hfs/rules/edition.mjs +49 -37
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +4 -5
- package/runtime/scripts/hfs/rules/fe-stack.mjs +71 -0
- package/scaffold/add-app.mjs +24 -18
- package/scaffold/add-cli-lite.mjs +33 -3
- package/scaffold/add-inbox-lite.mjs +24 -8
- package/scaffold/add-table.mjs +222 -74
- package/scaffold/add.mjs +85 -35
- package/scaffold/app.mjs +140 -36
- package/scaffold/edition-gate.mjs +21 -2
- package/scaffold/lite-exports.mjs +30 -1
- package/scaffold/service.mjs +5 -8
- package/src/main.mjs +2 -2
- package/sync/index.mjs +19 -15
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +14 -0
- package/templates/app/codegen-graphql.mjs.partial +34 -0
- package/templates/app/codegen-lite/scripts/codegen.mjs +18 -13
- package/templates/app/package-scripts/core.json +1 -0
- package/templates/app/package-scripts-lite/package.json +4 -0
- package/templates/app/skeleton/README.md +1 -1
- package/templates/app/skeleton/scripts/codegen.mjs +4 -1
- package/templates/app/skeleton-lite/README.md +7 -4
- package/templates/app/skeleton-lite/supabase/config.toml +1 -1
- package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +13 -14
- package/templates/be/inbox/database-client.spec.ts.tpl +47 -0
- package/templates/be/inbox/service.spec.ts.tpl +46 -0
- package/templates/be/inbox/webhook-door.spec.ts.tpl +76 -0
- package/templates/be/patterns/api/graphql.module.ts.tpl +2 -2
- package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +6 -3
- package/templates/be/platform/errors-graphql/graphql-error.mapper.spec.ts +52 -0
- package/templates/be/platform/errors-graphql/graphql-error.mapper.ts +31 -0
- package/templates/be/platform/graphql/graphql-depth.policy.spec.ts +43 -0
- package/templates/be/platform/graphql/graphql-depth.policy.ts +21 -0
- package/templates/be/platform/graphql/graphql.contracts.ts +12 -0
- package/templates/be/platform/graphql/graphql.module-definition.ts +7 -0
- package/templates/be/platform/graphql/graphql.module.ts +49 -0
- package/templates/be/platform/graphql/graphql.options.ts +2 -0
- package/templates/be/platform/graphql/index.ts +1 -0
- package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +114 -0
- package/templates/be/platform/graphql/localize-errors.mapper.ts +51 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +8 -3
- package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.spec.ts +13 -1
- package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +11 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +21 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +94 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +2 -4
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +10 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +2 -2
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +2 -6
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +15 -0
- package/templates/be/skeleton-lite/src/modules/platform/i18n/index.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/logging/index.ts +1 -1
- package/templates/be/table/api/create.command.ts.tpl +10 -0
- package/templates/be/table/api/create.contracts.ts.tpl +8 -0
- package/templates/be/table/api/create.handler.ts.tpl +22 -0
- package/templates/be/table/api/create.mapper.ts.tpl +5 -0
- package/templates/be/table/api/create.resolver.ts.tpl +21 -0
- package/templates/be/table/api/create.type.ts.tpl +9 -0
- package/templates/be/table/api/feature.index.ts.tpl +1 -0
- package/templates/be/table/api/feature.module.ts.tpl +7 -0
- package/templates/be/table/api/get.contracts.ts.tpl +11 -0
- package/templates/be/table/api/get.handler.ts.tpl +22 -0
- package/templates/be/table/api/get.input.ts.tpl +12 -0
- package/templates/be/table/api/get.mapper.ts.tpl +9 -0
- package/templates/be/table/api/get.query.ts.tpl +10 -0
- package/templates/be/table/api/get.resolver.ts.tpl +27 -0
- package/templates/be/table/api/get.type.ts.tpl +9 -0
- package/templates/be/table/api/graphql.module.ts.tpl +8 -0
- package/templates/be/table/error.api.ts.tpl +19 -0
- package/templates/be/table/migration.sql.tpl +13 -21
- package/templates/be/table/service.api.ts.tpl +30 -0
- package/templates/be/table/sql.api.ts.tpl +7 -0
- package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignInForm/index.tsx +16 -18
- package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignOutButton/component.tsx +7 -3
- package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignOutButton/index.tsx +23 -0
- package/templates/fe/skeleton/apps/__app__/src/components/providers/AppProviders/index.tsx +49 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/index.ts +4 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +6 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignIn.ts +29 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +27 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/effects/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/effects/useAuthSessionEffect.ts +14 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/swr/SwrSideEffects.tsx +8 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/swr/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/api/graphql/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/api/graphql/types.ts +26 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/api/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/client.ts +21 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/contracts.ts +27 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/index.ts +2 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/toast/api.ts +52 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/toast/hooks.ts +33 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/toast/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +32 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/types/ambient/styles.d.ts +2 -0
- package/templates/fe/skeleton/apps/__app__/src/redux/ReduxProvider.tsx +11 -0
- package/templates/fe/skeleton/apps/__app__/src/redux/hooks.ts +8 -0
- package/templates/fe/skeleton/apps/__app__/src/redux/index.ts +3 -0
- package/templates/fe/skeleton/apps/__app__/src/redux/slices/session.ts +26 -0
- package/templates/fe/skeleton/apps/__app__/src/redux/store.ts +14 -0
- package/templates/fe/skeleton/apps/app/src/app/[locale]/layout.tsx +5 -2
- package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/component.tsx +10 -2
- package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +1 -1
- package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/vi.json +23 -0
- package/templates/fe/skeleton/apps/landing/src/modules/types/ambient/styles.d.ts +2 -0
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +51 -0
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/providers.tsx +10 -13
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +8 -5
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +2 -17
- package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +3 -10
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/index.ts +0 -6
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +11 -8
- package/templates/fe/skeleton-lite/apps/web/src/modules/routes/index.ts +0 -3
- package/templates/fe/skeleton-lite/apps/web/src/proxy.ts +9 -25
- package/templates/fe/table/graphql/mutation.ts.tpl +25 -0
- package/templates/fe/table/graphql/mutation.types.ts.tpl +7 -0
- package/templates/fe/table/graphql/query.ts.tpl +25 -0
- package/templates/fe/table/graphql/query.types.ts.tpl +7 -0
- package/templates/fe/table/graphql/useMutateSwr.ts.tpl +21 -0
- package/templates/fe/table/graphql/useQuerySwr.ts.tpl +17 -0
- package/templates/fe/transport/graphql/clients/clients/auth.ts +39 -0
- package/templates/fe/transport/graphql/clients/clients/index.ts +2 -0
- package/templates/fe/transport/graphql/clients/clients/no-auth.ts +33 -0
- package/templates/fe/transport/graphql/clients/index.ts +2 -0
- package/templates/fe/transport/graphql/clients/links/attach-access-token.ts +9 -0
- package/templates/fe/transport/graphql/clients/links/error.ts +30 -0
- package/templates/fe/transport/graphql/clients/links/http.ts +16 -0
- package/templates/fe/transport/graphql/clients/links/index.ts +8 -0
- package/templates/fe/transport/graphql/clients/links/locale.ts +13 -0
- package/templates/fe/transport/graphql/clients/links/options.ts +17 -0
- package/templates/fe/transport/graphql/clients/links/refresh-token.ts +36 -0
- package/templates/fe/transport/graphql/clients/links/retry.ts +16 -0
- package/templates/fe/transport/graphql/clients/links/timeout.ts +28 -0
- package/templates/fe/transport/graphql/clients/response.ts +43 -0
- package/upgrade/check.mjs +1 -1
- package/upgrade/index.mjs +3 -2
- package/runtime/scripts/hfs/architecture/supabase-results.mjs +0 -182
- package/templates/app/quality-config-lite/sonar-project.properties +0 -5
- package/templates/be/patterns/api/feature.index.http.ts.tpl +0 -1
- package/templates/be/patterns/api/http.controller.ts.tpl +0 -27
- package/templates/be/patterns/api/http.mapper.ts.tpl +0 -9
- package/templates/be/patterns/api/http.module.ts.tpl +0 -7
- package/templates/be/patterns/api/http.request.ts.tpl +0 -10
- package/templates/be/patterns/api/http.response.ts.tpl +0 -5
- package/templates/fe/skeleton-lite/apps/web/src/app/auth/callback/route.ts +0 -24
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignOutButton/index.tsx +0 -38
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/read-session.ts +0 -25
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-in.ts +0 -25
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +0 -17
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/browser.ts +0 -14
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/index.ts +0 -3
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/outcome.ts +0 -59
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/principal.ts +0 -29
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/server.ts +0 -70
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +0 -58
- package/templates/fe/skeleton-lite/apps/web/src/modules/routes/safe-next-path.ts +0 -26
- package/templates/fe/table/read.ts.tpl +0 -16
- package/templates/fe/table/write.ts.tpl +0 -16
- /package/templates/be/patterns/api/{application.command.http.ts.tpl → application.command.principal.ts.tpl} +0 -0
- /package/templates/be/patterns/api/{application.handler.http.ts.tpl → application.handler.principal.ts.tpl} +0 -0
- /package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignInForm/component.tsx +0 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CheckCircleIcon,
|
|
3
|
+
ExclamationTriangleIcon,
|
|
4
|
+
InformationCircleIcon,
|
|
5
|
+
XCircleIcon,
|
|
6
|
+
} from "@heroicons/react/24/outline"
|
|
7
|
+
import { toast as heroToast } from "@heroui/react"
|
|
8
|
+
import type { ReactNode } from "react"
|
|
9
|
+
|
|
10
|
+
/** The options a status toast takes: HeroUI's, minus the variant the status already names. */
|
|
11
|
+
type StatusToastOptions = Parameters<typeof heroToast.success>[1]
|
|
12
|
+
|
|
13
|
+
/** The one indicator of each status, from the app's one icon set. */
|
|
14
|
+
const INDICATOR: Record<"success" | "danger" | "warning" | "info", ReactNode> = {
|
|
15
|
+
success: <CheckCircleIcon className="size-6 text-success-soft-foreground" />,
|
|
16
|
+
danger: <XCircleIcon className="size-6 text-danger-soft-foreground" />,
|
|
17
|
+
warning: <ExclamationTriangleIcon className="size-6 text-warning-soft-foreground" />,
|
|
18
|
+
info: <InformationCircleIcon className="size-6 text-accent" />,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The app's toast: HeroUI's imperative toast with the status indicator injected; the region is mounted by the providers. */
|
|
22
|
+
export const toast = {
|
|
23
|
+
...heroToast,
|
|
24
|
+
success: (message: ReactNode, options?: StatusToastOptions) =>
|
|
25
|
+
heroToast.success(message, { indicator: INDICATOR.success, ...options }),
|
|
26
|
+
danger: (message: ReactNode, options?: StatusToastOptions) =>
|
|
27
|
+
heroToast.danger(message, { indicator: INDICATOR.danger, ...options }),
|
|
28
|
+
warning: (message: ReactNode, options?: StatusToastOptions) =>
|
|
29
|
+
heroToast.warning(message, { indicator: INDICATOR.warning, ...options }),
|
|
30
|
+
info: (message: ReactNode, options?: StatusToastOptions) =>
|
|
31
|
+
heroToast.info(message, { indicator: INDICATOR.info, ...options }),
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import { Provider } from "react-redux"
|
|
5
|
+
import { store } from "./store"
|
|
6
|
+
|
|
7
|
+
/** What the store provider wraps: the routed tree. */
|
|
8
|
+
type ReduxProviderProps = { readonly children: ReactNode }
|
|
9
|
+
|
|
10
|
+
/** Mounts the app's one store above every route (components/providers/AppProviders). */
|
|
11
|
+
export const ReduxProvider = (props: ReduxProviderProps) => <Provider store={store}>{props.children}</Provider>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { useDispatch, useSelector } from "react-redux"
|
|
2
|
+
import type { AppDispatch, RootState } from "./store"
|
|
3
|
+
|
|
4
|
+
/** The typed dispatch of the app's store. */
|
|
5
|
+
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
|
|
6
|
+
|
|
7
|
+
/** The typed selector of the app's store. */
|
|
8
|
+
export const useAppSelector = useSelector.withTypes<RootState>()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createSlice } from "@reduxjs/toolkit"
|
|
2
|
+
import type { PayloadAction } from "@reduxjs/toolkit"
|
|
3
|
+
import type { AuthSession } from "@/modules/auth"
|
|
4
|
+
|
|
5
|
+
/** The reader's session as the app shares it: unknown until modules/auth answers, then signed in or signed out. */
|
|
6
|
+
export interface SessionState {
|
|
7
|
+
readonly status: "unknown" | "signed-in" | "signed-out"
|
|
8
|
+
readonly session?: AuthSession
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const initialState: SessionState = { status: "unknown" }
|
|
12
|
+
|
|
13
|
+
/** The session slice: written only by the auth session effect (hooks/effects), read by any component through useAppSelector. */
|
|
14
|
+
export const sessionSlice = createSlice({
|
|
15
|
+
name: "session",
|
|
16
|
+
initialState,
|
|
17
|
+
reducers: {
|
|
18
|
+
sessionChanged: (_state, action: PayloadAction<AuthSession | undefined>): SessionState =>
|
|
19
|
+
action.payload === undefined ? { status: "signed-out" } : { status: "signed-in", session: action.payload },
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
/** The one action of the session slice: modules/auth reported a session (or none). */
|
|
24
|
+
export const { sessionChanged } = sessionSlice.actions
|
|
25
|
+
/** The reducer the store mounts under `session`. */
|
|
26
|
+
export const sessionReducer = sessionSlice.reducer
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { configureStore } from "@reduxjs/toolkit"
|
|
2
|
+
import { sessionReducer } from "./slices/session"
|
|
3
|
+
|
|
4
|
+
/** The one store of the app: app-wide shared state only (feature-local state lives in hooks/zustand). */
|
|
5
|
+
export const store = configureStore({
|
|
6
|
+
reducer: {
|
|
7
|
+
session: sessionReducer,
|
|
8
|
+
},
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
/** The state of the store, for useAppSelector. */
|
|
12
|
+
export type RootState = ReturnType<typeof store.getState>
|
|
13
|
+
/** The dispatch of the store, for useAppDispatch. */
|
|
14
|
+
export type AppDispatch = typeof store.dispatch
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from "react"
|
|
2
2
|
import { LocaleShell } from "@{{project}}/ui"
|
|
3
|
+
import { AppProviders } from "@/components/providers/AppProviders"
|
|
3
4
|
import { AppLayout, appLayoutMetadata } from "@/features/layouts/AppLayout"
|
|
4
5
|
import { appI18n } from "@/modules/i18n"
|
|
5
6
|
import "../globals.css"
|
|
@@ -12,10 +13,12 @@ type LayoutProps = {
|
|
|
12
13
|
/** The localized document title of this route segment. */
|
|
13
14
|
export const generateMetadata = async (props: LayoutProps) => appLayoutMetadata((await props.params).locale)
|
|
14
15
|
|
|
15
|
-
/** The locale segment's layout slot: the document of the address's language
|
|
16
|
+
/** The locale segment's layout slot: the document of the address's language, the app's client providers and its chrome. */
|
|
16
17
|
const Layout = async (props: LayoutProps) => (
|
|
17
18
|
<LocaleShell {...await appI18n.readLocaleSegment(props.params)}>
|
|
18
|
-
<
|
|
19
|
+
<AppProviders>
|
|
20
|
+
<AppLayout content={props.children} />
|
|
21
|
+
</AppProviders>
|
|
19
22
|
</LocaleShell>
|
|
20
23
|
)
|
|
21
24
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Heading } from "@starci/grammar/common"
|
|
2
|
+
import { SignInForm } from "@/components/blocks/SignInForm"
|
|
3
|
+
import { SignOutButton } from "@/components/blocks/SignOutButton"
|
|
2
4
|
|
|
3
5
|
/** Props for {@link AppHomePageBase}. */
|
|
4
6
|
export type AppHomePageBaseProps = {
|
|
@@ -6,5 +8,11 @@ export type AppHomePageBaseProps = {
|
|
|
6
8
|
readonly props: { readonly title: string }
|
|
7
9
|
}
|
|
8
10
|
|
|
9
|
-
/** Draw the front door of the product app. */
|
|
10
|
-
export const AppHomePageBase = (props: AppHomePageBaseProps) =>
|
|
11
|
+
/** Draw the front door of the product app: the sign-in form for a signed-out reader, the reader and sign-out once signed in. */
|
|
12
|
+
export const AppHomePageBase = (props: AppHomePageBaseProps) => (
|
|
13
|
+
<>
|
|
14
|
+
<Heading level={1}>{props.props.title}</Heading>
|
|
15
|
+
<SignInForm />
|
|
16
|
+
<SignOutButton />
|
|
17
|
+
</>
|
|
18
|
+
)
|
|
@@ -8,7 +8,7 @@ export const appHomeMetadata = async (): Promise<Metadata> => {
|
|
|
8
8
|
return { title: t("title") }
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
/** The first page of the product app: a server component
|
|
11
|
+
/** The first page of the product app: a server component; the session surface it draws runs on the client. */
|
|
12
12
|
export const AppHomePage = async () => {
|
|
13
13
|
const t = await getTranslations("app.home")
|
|
14
14
|
return <AppHomePageBase props={{ title: t("title") }} />
|
|
@@ -7,6 +7,29 @@
|
|
|
7
7
|
"home": {
|
|
8
8
|
"title": "Trang chủ"
|
|
9
9
|
},
|
|
10
|
+
"signIn": {
|
|
11
|
+
"title": "Đăng nhập",
|
|
12
|
+
"email": "Email",
|
|
13
|
+
"password": "Mật khẩu",
|
|
14
|
+
"submit": "Tiếp tục",
|
|
15
|
+
"working": "Đang đăng nhập",
|
|
16
|
+
"refused": "Thông tin đăng nhập không đúng.",
|
|
17
|
+
"invalid": "Hãy nhập email và mật khẩu hợp lệ.",
|
|
18
|
+
"unavailable": "Chưa thể đăng nhập. Hãy thử lại sau."
|
|
19
|
+
},
|
|
20
|
+
"signOut": {
|
|
21
|
+
"signedIn": "Đã đăng nhập",
|
|
22
|
+
"submit": "Đăng xuất",
|
|
23
|
+
"working": "Đang đăng xuất"
|
|
24
|
+
},
|
|
25
|
+
"toast": {
|
|
26
|
+
"successTitle": "Thành công",
|
|
27
|
+
"errorTitle": "Lỗi",
|
|
28
|
+
"unauthorizedTitle": "Chưa đăng nhập",
|
|
29
|
+
"unauthorizedDescription": "Hãy đăng nhập để tiếp tục.",
|
|
30
|
+
"defaultSuccess": "Đã hoàn tất.",
|
|
31
|
+
"defaultError": "Đã xảy ra lỗi."
|
|
32
|
+
},
|
|
10
33
|
"loading": {
|
|
11
34
|
"message": "Đang tải"
|
|
12
35
|
},
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createClient } from "@supabase/supabase-js"
|
|
2
|
+
import type { Session, SupabaseClient } from "@supabase/supabase-js"
|
|
3
|
+
import { supabaseAnonKey, supabaseUrl } from "@/modules/config"
|
|
4
|
+
import type { AuthClient, AuthFailure, AuthSession } from "./contracts"
|
|
5
|
+
|
|
6
|
+
let client: SupabaseClient | undefined
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The one Supabase client of the front end, used for Supabase Auth only (the PKCE flow runs in the browser: a returning
|
|
10
|
+
* redirect's code is exchanged by `detectSessionInUrl`, no route handler). It never calls `.from()`, `.rpc()` or storage:
|
|
11
|
+
* data goes through modules/api to the back end, which verifies the access token.
|
|
12
|
+
*/
|
|
13
|
+
const supabase = (): SupabaseClient =>
|
|
14
|
+
(client ??= createClient(supabaseUrl(), supabaseAnonKey(), {
|
|
15
|
+
auth: { flowType: "pkce", detectSessionInUrl: true, persistSession: true, autoRefreshToken: true },
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
const sessionOf = (session: Session | null): AuthSession | undefined =>
|
|
19
|
+
session === null
|
|
20
|
+
? undefined
|
|
21
|
+
: { userId: session.user.id, ...(session.user.email === undefined ? {} : { email: session.user.email }) }
|
|
22
|
+
|
|
23
|
+
/** A Supabase Auth error as the provider reports it: its HTTP status, its code and its message. */
|
|
24
|
+
type ProviderError = { readonly status?: number; readonly code?: string; readonly message: string }
|
|
25
|
+
|
|
26
|
+
/** The failure kind of a Supabase Auth error: refused credentials, rejected input, or the provider unavailable. */
|
|
27
|
+
const kindOf = (status: number | undefined): string => {
|
|
28
|
+
if (status === 400 || status === 401 || status === 403) return "refused"
|
|
29
|
+
return status === 422 ? "invalid" : "unavailable"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The failure of a Supabase Auth call, or undefined when it succeeded. */
|
|
33
|
+
const failureOf = (error: ProviderError | null): AuthFailure | undefined =>
|
|
34
|
+
error === null
|
|
35
|
+
? undefined
|
|
36
|
+
: { code: error.code ?? String(error.status ?? "auth"), kind: kindOf(error.status), message: error.message }
|
|
37
|
+
|
|
38
|
+
/** The identity-provider client of a lite app: Supabase Auth in the browser. */
|
|
39
|
+
export const authClient: AuthClient = {
|
|
40
|
+
session: async () => sessionOf((await supabase().auth.getSession()).data.session),
|
|
41
|
+
accessToken: async () => (await supabase().auth.getSession()).data.session?.access_token,
|
|
42
|
+
refreshAccessToken: async () => (await supabase().auth.refreshSession()).data.session?.access_token,
|
|
43
|
+
signInWithPassword: async (email, password) =>
|
|
44
|
+
failureOf((await supabase().auth.signInWithPassword({ email, password })).error),
|
|
45
|
+
signOut: async () => failureOf((await supabase().auth.signOut()).error),
|
|
46
|
+
// Supabase reports INITIAL_SESSION on subscribe, then every change: the subscription alone keeps the session current.
|
|
47
|
+
onChange: (listener) => {
|
|
48
|
+
const { data } = supabase().auth.onAuthStateChange((_event, session) => listener(sessionOf(session)))
|
|
49
|
+
return () => data.subscription.unsubscribe()
|
|
50
|
+
},
|
|
51
|
+
}
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { I18nProvider } from "@heroui/react"
|
|
4
4
|
import { NextIntlClientProvider } from "next-intl"
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { navigation } from "@/modules/i18n"
|
|
5
|
+
import type { ReactNode } from "react"
|
|
6
|
+
import { AppProviders } from "@/components/providers/AppProviders"
|
|
8
7
|
|
|
9
8
|
/** What the provider stack takes from the locale layout. */
|
|
10
9
|
type ProvidersProps = {
|
|
@@ -14,13 +13,11 @@ type ProvidersProps = {
|
|
|
14
13
|
readonly children: ReactNode
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
/** The copy
|
|
18
|
-
export const Providers = (props: ProvidersProps) =>
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
)
|
|
26
|
-
}
|
|
16
|
+
/** The copy and React Aria contexts above every route, around the app's client providers (components/providers). */
|
|
17
|
+
export const Providers = (props: ProvidersProps) => (
|
|
18
|
+
<NextIntlClientProvider locale={props.locale} messages={props.messages} timeZone={props.timeZone}>
|
|
19
|
+
<I18nProvider locale={props.locale}>
|
|
20
|
+
<AppProviders>{props.children}</AppProviders>
|
|
21
|
+
</I18nProvider>
|
|
22
|
+
</NextIntlClientProvider>
|
|
23
|
+
)
|
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { Heading
|
|
1
|
+
import { Heading } from "@starci/grammar/common"
|
|
2
|
+
import { SignInForm } from "@/components/blocks/SignInForm"
|
|
3
|
+
import { SignOutButton } from "@/components/blocks/SignOutButton"
|
|
2
4
|
|
|
3
|
-
/** Props for the
|
|
5
|
+
/** Props for the home page: its title. */
|
|
4
6
|
export type AppHomePageBaseProps = {
|
|
5
|
-
readonly props: { readonly title: string
|
|
7
|
+
readonly props: { readonly title: string }
|
|
6
8
|
}
|
|
7
9
|
|
|
8
|
-
/** Draws the
|
|
10
|
+
/** Draws the front door: the sign-in form for a signed-out reader, the reader and sign-out once signed in. */
|
|
9
11
|
export const AppHomePageBase = (props: AppHomePageBaseProps) => (
|
|
10
12
|
<>
|
|
11
13
|
<Heading level={1}>{props.props.title}</Heading>
|
|
12
|
-
<
|
|
14
|
+
<SignInForm />
|
|
15
|
+
<SignOutButton />
|
|
13
16
|
</>
|
|
14
17
|
)
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import type { Metadata } from "next"
|
|
2
2
|
import { getTranslations } from "next-intl/server"
|
|
3
|
-
import { SignInForm } from "@/components/blocks/SignInForm"
|
|
4
|
-
import { SignOutButton } from "@/components/blocks/SignOutButton"
|
|
5
|
-
import { readSession } from "@/modules/db"
|
|
6
3
|
import { AppHomePageBase } from "./component"
|
|
7
4
|
|
|
8
5
|
/** The document title of the home page. */
|
|
@@ -11,20 +8,8 @@ export const appHomeMetadata = async (): Promise<Metadata> => {
|
|
|
11
8
|
return { title: t("title") }
|
|
12
9
|
}
|
|
13
10
|
|
|
14
|
-
/** The front door
|
|
11
|
+
/** The front door (the session is client-side, drawn by the blocks the page composes). */
|
|
15
12
|
export const AppHomePage = async () => {
|
|
16
|
-
const session = await readSession()
|
|
17
|
-
if (session.kind !== "ok") return <SignInForm />
|
|
18
13
|
const t = await getTranslations("app.home")
|
|
19
|
-
return (
|
|
20
|
-
<>
|
|
21
|
-
<AppHomePageBase
|
|
22
|
-
props={{
|
|
23
|
-
title: t("title"),
|
|
24
|
-
principal: session.value.email ?? t("signedIn"),
|
|
25
|
-
}}
|
|
26
|
-
/>
|
|
27
|
-
<SignOutButton />
|
|
28
|
-
</>
|
|
29
|
-
)
|
|
14
|
+
return <AppHomePageBase props={{ title: t("title") }} />
|
|
30
15
|
}
|
|
@@ -4,17 +4,10 @@ const required = (name: string, value: string | undefined): string => {
|
|
|
4
4
|
return value
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
/** The public origin used for metadata
|
|
7
|
+
/** The public origin used for metadata. */
|
|
8
8
|
export const siteUrl = (): string => required("NEXT_PUBLIC_SITE_URL", process.env.NEXT_PUBLIC_SITE_URL)
|
|
9
|
-
/** The public Supabase project origin. */
|
|
9
|
+
/** The public Supabase project origin, read by modules/auth only. */
|
|
10
10
|
export const supabaseUrl = (): string => required("NEXT_PUBLIC_SUPABASE_URL", process.env.NEXT_PUBLIC_SUPABASE_URL)
|
|
11
|
-
/** The public anonymous key
|
|
11
|
+
/** The public anonymous key of Supabase Auth; the back end verifies every access token it is sent. */
|
|
12
12
|
export const supabaseAnonKey = (): string =>
|
|
13
13
|
required("NEXT_PUBLIC_SUPABASE_ANON_KEY", process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
|
|
14
|
-
|
|
15
|
-
/** Security attributes applied whenever the app writes a Supabase session cookie. */
|
|
16
|
-
export const sessionCookieOptions = {
|
|
17
|
-
httpOnly: true,
|
|
18
|
-
secure: process.env.NODE_ENV === "production",
|
|
19
|
-
sameSite: "lax",
|
|
20
|
-
} as const
|
|
@@ -1,8 +1,2 @@
|
|
|
1
|
-
import { createNavigation } from "next-intl/navigation"
|
|
2
|
-
import { routing } from "./routing"
|
|
3
|
-
|
|
4
1
|
/** The locale served at the unprefixed path and used by the global error boundary. */
|
|
5
2
|
export { DEFAULT_LOCALE, PRODUCT_TIME_ZONE, routing } from "./routing"
|
|
6
|
-
|
|
7
|
-
/** Locale-aware navigation for client components. */
|
|
8
|
-
export const navigation = createNavigation(routing)
|
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
"brand": "{{project}}"
|
|
6
6
|
},
|
|
7
7
|
"home": {
|
|
8
|
-
"title": "Home"
|
|
9
|
-
"signedIn": "Signed in"
|
|
8
|
+
"title": "Home"
|
|
10
9
|
},
|
|
11
10
|
"signIn": {
|
|
12
11
|
"title": "Sign in",
|
|
@@ -15,17 +14,21 @@
|
|
|
15
14
|
"submit": "Continue",
|
|
16
15
|
"working": "Signing in",
|
|
17
16
|
"refused": "The credentials were refused.",
|
|
18
|
-
"not-found": "The credentials were refused.",
|
|
19
17
|
"invalid": "Enter a valid email and password.",
|
|
20
18
|
"unavailable": "Sign-in is unavailable. Try again shortly."
|
|
21
19
|
},
|
|
22
20
|
"signOut": {
|
|
21
|
+
"signedIn": "Signed in",
|
|
23
22
|
"submit": "Sign out",
|
|
24
|
-
"working": "Signing out"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
"
|
|
23
|
+
"working": "Signing out"
|
|
24
|
+
},
|
|
25
|
+
"toast": {
|
|
26
|
+
"successTitle": "Done",
|
|
27
|
+
"errorTitle": "Error",
|
|
28
|
+
"unauthorizedTitle": "Not signed in",
|
|
29
|
+
"unauthorizedDescription": "Sign in to continue.",
|
|
30
|
+
"defaultSuccess": "The change was saved.",
|
|
31
|
+
"defaultError": "Something went wrong."
|
|
29
32
|
},
|
|
30
33
|
"loading": {
|
|
31
34
|
"message": "Loading"
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import createMiddleware from "next-intl/middleware"
|
|
2
2
|
import { NextResponse } from "next/server"
|
|
3
3
|
import type { NextRequest } from "next/server"
|
|
4
|
-
import { sessionCookieOptions } from "@/modules/config"
|
|
5
|
-
import { refreshSession } from "@/modules/db"
|
|
6
4
|
import { routing } from "@/modules/i18n"
|
|
7
|
-
import { APP_ROUTES } from "@/modules/routes"
|
|
8
5
|
|
|
9
|
-
|
|
6
|
+
/** The paths outside locale routing: the liveness route and the framework's own assets. */
|
|
7
|
+
const UNNEGOTIATED_ROOTS: ReadonlySet<string> = new Set(["health", "_next", "_vercel"])
|
|
10
8
|
const negotiate = createMiddleware(routing)
|
|
11
9
|
|
|
12
10
|
const firstSegment = (pathname: string): string => pathname.split("/")[1] ?? ""
|
|
@@ -16,28 +14,14 @@ const isUnnegotiated = (pathname: string): boolean => {
|
|
|
16
14
|
return UNNEGOTIATED_ROOTS.has(firstSegment(pathname)) || (last.includes(".") && !last.endsWith("."))
|
|
17
15
|
}
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Locale routing only. The session lives in the browser (modules/auth) and the back end verifies the access token of
|
|
19
|
+
* every call, so the proxy neither reads nor refreshes it.
|
|
20
|
+
*/
|
|
21
|
+
export const proxy = (request: NextRequest): NextResponse =>
|
|
22
|
+
isUnnegotiated(request.nextUrl.pathname) ? NextResponse.next() : negotiate(request)
|
|
21
23
|
|
|
22
|
-
/**
|
|
23
|
-
export const proxy = async (request: NextRequest): Promise<NextResponse> => {
|
|
24
|
-
const response = isUnnegotiated(request.nextUrl.pathname) ? NextResponse.next() : negotiate(request)
|
|
25
|
-
const principal = await refreshSession(request, response)
|
|
26
|
-
if (principal.kind === "ok" || isPublic(request.nextUrl.pathname)) return response
|
|
27
|
-
const destination = request.nextUrl.clone()
|
|
28
|
-
destination.pathname = APP_ROUTES.home
|
|
29
|
-
destination.searchParams.set("next", request.nextUrl.pathname)
|
|
30
|
-
const redirect = NextResponse.redirect(destination)
|
|
31
|
-
for (const cookie of response.cookies.getAll()) {
|
|
32
|
-
redirect.cookies.set(cookie.name, cookie.value, {
|
|
33
|
-
...cookie,
|
|
34
|
-
...sessionCookieOptions,
|
|
35
|
-
})
|
|
36
|
-
}
|
|
37
|
-
return redirect
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Routes that pass through session refresh and locale negotiation. */
|
|
24
|
+
/** Routes that pass through locale negotiation. */
|
|
41
25
|
export const config = {
|
|
42
26
|
matcher: "/((?!_next/static|_next/image|favicon.ico).*)",
|
|
43
27
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { gql } from "@apollo/client"
|
|
2
|
+
import type { TypedDocumentNode } from "@apollo/client"
|
|
3
|
+
import type { Create{{Singular}}Mutation, Create{{Singular}}MutationVariables } from "../__generated__/graphql"
|
|
4
|
+
import { createAuthApolloClient, graphqlResponseOf } from "../clients"
|
|
5
|
+
import type { GraphQLResponse, OperationParams } from "../types"
|
|
6
|
+
import type { MutateCreate{{Singular}}Data, MutateCreate{{Singular}}Request } from "./types/create-{{singular}}"
|
|
7
|
+
|
|
8
|
+
/** The `create{{Singular}}` mutation of the back end (be/src/features/api/{{name}}, create-{{singular}}). */
|
|
9
|
+
const MUTATION_CREATE_{{singularUpper}}: TypedDocumentNode<Create{{Singular}}Mutation, Create{{Singular}}MutationVariables> = gql`
|
|
10
|
+
mutation Create{{Singular}} {
|
|
11
|
+
create{{Singular}} {
|
|
12
|
+
id
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
`
|
|
16
|
+
|
|
17
|
+
/** Creates one {{singular}} row owned by the signed-in reader. */
|
|
18
|
+
export const mutateCreate{{Singular}} = async ({
|
|
19
|
+
request,
|
|
20
|
+
signal,
|
|
21
|
+
}: OperationParams<MutateCreate{{Singular}}Request>): Promise<GraphQLResponse<MutateCreate{{Singular}}Data>> =>
|
|
22
|
+
graphqlResponseOf(
|
|
23
|
+
await createAuthApolloClient({ signal }).mutate({ mutation: MUTATION_CREATE_{{singularUpper}}, variables: request }),
|
|
24
|
+
(data) => data.create{{Singular}},
|
|
25
|
+
)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Create{{Singular}}Mutation, Create{{Singular}}MutationVariables } from "../../__generated__/graphql"
|
|
2
|
+
|
|
3
|
+
/** What mutateCreate{{Singular}} takes: the variables of the `create{{Singular}}` mutation (none: the caller owns the row). */
|
|
4
|
+
export type MutateCreate{{Singular}}Request = Create{{Singular}}MutationVariables
|
|
5
|
+
|
|
6
|
+
/** What mutateCreate{{Singular}} answers: the `create{{Singular}}` field. */
|
|
7
|
+
export type MutateCreate{{Singular}}Data = Create{{Singular}}Mutation["create{{Singular}}"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { gql } from "@apollo/client"
|
|
2
|
+
import type { TypedDocumentNode } from "@apollo/client"
|
|
3
|
+
import type { {{Singular}}Query, {{Singular}}QueryVariables } from "../__generated__/graphql"
|
|
4
|
+
import { createAuthApolloClient, graphqlResponseOf } from "../clients"
|
|
5
|
+
import type { GraphQLResponse, OperationParams } from "../types"
|
|
6
|
+
import type { Query{{Singular}}Data, Query{{Singular}}Request } from "./types/{{singular}}"
|
|
7
|
+
|
|
8
|
+
/** The `{{singularCamel}}` query of the back end (be/src/features/api/{{name}}, get-{{singular}}). */
|
|
9
|
+
const QUERY_{{singularUpper}}: TypedDocumentNode<{{Singular}}Query, {{Singular}}QueryVariables> = gql`
|
|
10
|
+
query {{Singular}}($input: Get{{Singular}}Input!) {
|
|
11
|
+
{{singularCamel}}(input: $input) {
|
|
12
|
+
id
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
`
|
|
16
|
+
|
|
17
|
+
/** Reads one {{singular}} row of the signed-in reader. */
|
|
18
|
+
export const query{{Singular}} = async ({
|
|
19
|
+
request,
|
|
20
|
+
signal,
|
|
21
|
+
}: OperationParams<Query{{Singular}}Request>): Promise<GraphQLResponse<Query{{Singular}}Data>> =>
|
|
22
|
+
graphqlResponseOf(
|
|
23
|
+
await createAuthApolloClient({ signal }).query({ query: QUERY_{{singularUpper}}, variables: { input: request } }),
|
|
24
|
+
(data) => data.{{singularCamel}},
|
|
25
|
+
)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { {{Singular}}Query, {{Singular}}QueryVariables } from "../../__generated__/graphql"
|
|
2
|
+
|
|
3
|
+
/** What query{{Singular}} takes: the input of the `{{singularCamel}}` query (generated from the back end's contract). */
|
|
4
|
+
export type Query{{Singular}}Request = {{Singular}}QueryVariables["input"]
|
|
5
|
+
|
|
6
|
+
/** What query{{Singular}} answers: the `{{singularCamel}}` field. */
|
|
7
|
+
export type Query{{Singular}}Data = {{Singular}}Query["{{singularCamel}}"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useSWRConfig } from "swr"
|
|
2
|
+
import useSWRMutation from "swr/mutation"
|
|
3
|
+
import { mutateCreate{{Singular}} } from "@/modules/api/graphql/mutations/mutation-create-{{singular}}"
|
|
4
|
+
import { QUERY_{{singularUpper}}_SWR_KEY } from "../queries/useQuery{{Singular}}Swr"
|
|
5
|
+
|
|
6
|
+
/** The SWR key of the create-{{singular}} mutation. */
|
|
7
|
+
export const MUTATE_CREATE_{{singularUpper}}_SWR_KEY = "MUTATE_CREATE_{{singularUpper}}_SWR"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Creates one {{singular}} row; answers the GraphQLResponse so the caller reports it through modules/toast
|
|
11
|
+
* (useGraphQLWithToast). A success primes the {{singular}} query of the new row, the resource the mutation is tied to.
|
|
12
|
+
*/
|
|
13
|
+
export const useMutateCreate{{Singular}}Swr = () => {
|
|
14
|
+
const { mutate } = useSWRConfig()
|
|
15
|
+
return useSWRMutation(MUTATE_CREATE_{{singularUpper}}_SWR_KEY, async () => {
|
|
16
|
+
const response = await mutateCreate{{Singular}}({ request: {} })
|
|
17
|
+
if (response.success)
|
|
18
|
+
await mutate([QUERY_{{singularUpper}}_SWR_KEY, response.data.id], response.data, { revalidate: false })
|
|
19
|
+
return response
|
|
20
|
+
})
|
|
21
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import useSWR from "swr"
|
|
2
|
+
import { query{{Singular}} } from "@/modules/api/graphql/queries/query-{{singular}}"
|
|
3
|
+
import type { Query{{Singular}}Data } from "@/modules/api/graphql/queries/types/{{singular}}"
|
|
4
|
+
|
|
5
|
+
/** The SWR key of the {{singular}} query; the full key carries the row id, the identity of its result. */
|
|
6
|
+
export const QUERY_{{singularUpper}}_SWR_KEY = "QUERY_{{singularUpper}}_SWR"
|
|
7
|
+
|
|
8
|
+
/** One {{singular}} row of the signed-in reader by id; no id, no request. A failure is thrown as the GraphQLResponse's message. */
|
|
9
|
+
export const useQuery{{Singular}}Swr = (id: string | undefined) =>
|
|
10
|
+
useSWR(
|
|
11
|
+
id === undefined ? null : ([QUERY_{{singularUpper}}_SWR_KEY, id] as const),
|
|
12
|
+
async ([, rowId]): Promise<Query{{Singular}}Data> => {
|
|
13
|
+
const response = await query{{Singular}}({ request: { id: rowId } })
|
|
14
|
+
if (!response.success) throw new Error(response.message, { cause: response })
|
|
15
|
+
return response.data
|
|
16
|
+
},
|
|
17
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ApolloClient, ApolloLink, InMemoryCache } from "@apollo/client"
|
|
2
|
+
import { authClient } from "@/modules/auth"
|
|
3
|
+
import {
|
|
4
|
+
createAttachAccessTokenLink,
|
|
5
|
+
createAttachLocaleLink,
|
|
6
|
+
createErrorLink,
|
|
7
|
+
createHttpLink,
|
|
8
|
+
createRefreshTokenLink,
|
|
9
|
+
createRetryLink,
|
|
10
|
+
createTimeoutLink,
|
|
11
|
+
defaultOptions,
|
|
12
|
+
} from "../links"
|
|
13
|
+
import type { ErrorReporter } from "../links/error"
|
|
14
|
+
|
|
15
|
+
/** What the authenticated client takes: the abort signal of its operations and where the error link reports (none: it stays silent). */
|
|
16
|
+
export interface CreateAuthApolloClientOptions {
|
|
17
|
+
readonly signal?: AbortSignal
|
|
18
|
+
readonly report?: ErrorReporter
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The authenticated Apollo client. The link chain runs outer to inner:
|
|
23
|
+
* retry (transient network failures) -> error (report) -> timeout -> refresh (one token refresh and replay on
|
|
24
|
+
* UNAUTHENTICATED) -> locale (`x-locale`) -> access token (`Authorization: Bearer`, from modules/auth) -> http (terminal).
|
|
25
|
+
*/
|
|
26
|
+
export const createAuthApolloClient = ({ signal, report }: CreateAuthApolloClientOptions = {}) =>
|
|
27
|
+
new ApolloClient({
|
|
28
|
+
link: ApolloLink.from([
|
|
29
|
+
createRetryLink(),
|
|
30
|
+
createErrorLink(report),
|
|
31
|
+
createTimeoutLink(),
|
|
32
|
+
createRefreshTokenLink(() => authClient.refreshAccessToken()),
|
|
33
|
+
createAttachLocaleLink(),
|
|
34
|
+
createAttachAccessTokenLink(() => authClient.accessToken()),
|
|
35
|
+
createHttpLink({ signal }),
|
|
36
|
+
]),
|
|
37
|
+
cache: new InMemoryCache(),
|
|
38
|
+
defaultOptions,
|
|
39
|
+
})
|