@sjolystinnovation/app-kit 0.21.0 → 1.0.0

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.
Files changed (99) hide show
  1. package/README.md +1287 -860
  2. package/package.json +21 -4
  3. package/src/admin/AdminShell.tsx +2 -1
  4. package/src/admin/AdminUsersPanel.tsx +93 -37
  5. package/src/admin/CompanySettingsForm.tsx +35 -14
  6. package/src/admin/EmailStatsPanel.tsx +76 -0
  7. package/src/admin/LegalPagesEditor.tsx +45 -17
  8. package/src/admin/LocaleTabs.tsx +4 -3
  9. package/src/admin/StatHistoryChart.tsx +4 -3
  10. package/src/admin/adminUsersPanelStrings.ts +70 -3
  11. package/src/admin/companySettingsFormStrings.ts +26 -0
  12. package/src/admin/emailStatsPanelStrings.ts +29 -0
  13. package/src/admin/index.ts +14 -5
  14. package/src/admin/legalPagesEditorStrings.ts +26 -1
  15. package/src/admin/statHistoryChartStrings.ts +3 -2
  16. package/src/api/apiErrorCode.ts +28 -0
  17. package/src/api/apiFieldErrors.ts +84 -13
  18. package/src/api/company.ts +4 -3
  19. package/src/api/createApiClient.ts +3 -2
  20. package/src/api/failureBody.ts +10 -0
  21. package/src/api/fieldErrorText.ts +32 -0
  22. package/src/{errors.ts → api/formatApiError.ts} +12 -12
  23. package/src/api/index.ts +7 -2
  24. package/src/api/legalPage.ts +10 -0
  25. package/src/api/request.ts +14 -4
  26. package/src/api/revalidateTargets.ts +7 -5
  27. package/src/api/services.ts +53 -22
  28. package/src/auth/createAuthOptions.ts +16 -8
  29. package/src/branding/BrandingManager.tsx +37 -37
  30. package/src/branding/BrandingProvider.tsx +5 -3
  31. package/src/branding/brandingManagerStrings.ts +49 -0
  32. package/src/branding/index.ts +4 -5
  33. package/src/branding/server.ts +10 -9
  34. package/src/branding/types.ts +3 -3
  35. package/src/config.ts +10 -10
  36. package/src/forms/useFormDraft.ts +9 -7
  37. package/src/index.ts +1 -3
  38. package/src/legal/LegalPageView.tsx +5 -19
  39. package/src/legal/index.ts +4 -1
  40. package/src/legal/keys.ts +10 -0
  41. package/src/legal/legalPageViewStrings.ts +17 -0
  42. package/src/locales/nb.ts +330 -0
  43. package/src/markdown/Markdown.tsx +12 -10
  44. package/src/markdown/index.ts +1 -1
  45. package/src/next-auth.d.ts +11 -9
  46. package/src/next-config/headers.ts +96 -0
  47. package/src/next-config/index.ts +18 -107
  48. package/src/payments/PaymentReturn.tsx +9 -7
  49. package/src/payments/formatNok.ts +1 -1
  50. package/src/payments/usePollUntilFinal.ts +7 -5
  51. package/src/sections/SectionRenderer.tsx +275 -0
  52. package/src/sections/SectionsEditor.tsx +151 -0
  53. package/src/sections/SmartLink.tsx +42 -0
  54. package/src/sections/editors.tsx +311 -0
  55. package/src/sections/factory.ts +26 -0
  56. package/src/sections/index.ts +69 -0
  57. package/src/sections/sectionsEditorStrings.ts +108 -0
  58. package/src/sections/types.ts +107 -0
  59. package/src/seo/JsonLd.tsx +1 -1
  60. package/src/seo/environment.ts +3 -3
  61. package/src/seo/index.ts +1 -1
  62. package/src/seo/og/index.tsx +5 -4
  63. package/src/seo/schema.ts +6 -2
  64. package/src/seo/site.ts +49 -37
  65. package/src/seo/sitemap.ts +2 -2
  66. package/src/server/publicApi.ts +13 -9
  67. package/src/server/revalidateRoute.ts +10 -8
  68. package/src/session/expiry.ts +11 -6
  69. package/src/session/index.ts +0 -1
  70. package/src/session/react/AppSessionProvider.tsx +3 -2
  71. package/src/{ui → session/react}/CredentialsForm.tsx +17 -10
  72. package/src/{ui → session/react}/LoginPage.tsx +7 -6
  73. package/src/session/react/ProtectedGate.tsx +3 -2
  74. package/src/session/react/RoleGate.tsx +4 -3
  75. package/src/session/react/SessionExpiryGuard.tsx +5 -4
  76. package/src/{ui → session/react}/credentialsFormStrings.ts +4 -1
  77. package/src/session/react/index.ts +8 -2
  78. package/src/{ui → session/react}/loginPageStrings.ts +1 -1
  79. package/src/session/react/sessionExpiryGuardStrings.ts +1 -1
  80. package/src/session/react/useSessionGate.ts +3 -3
  81. package/src/styles.css +2 -0
  82. package/src/testing/index.ts +5 -13
  83. package/src/ui/Alert.tsx +6 -4
  84. package/src/ui/ContentImage.tsx +12 -11
  85. package/src/ui/ErrorState.tsx +5 -17
  86. package/src/ui/NotFoundState.tsx +7 -17
  87. package/src/ui/PasswordInput.tsx +6 -5
  88. package/src/ui/ResetPasswordFlow.tsx +64 -19
  89. package/src/ui/TextArea.tsx +6 -4
  90. package/src/ui/TextInput.tsx +4 -2
  91. package/src/ui/Toggle.tsx +3 -3
  92. package/src/ui/errorStateStrings.ts +16 -0
  93. package/src/ui/field.tsx +5 -3
  94. package/src/ui/index.ts +10 -14
  95. package/src/ui/notFoundStateStrings.ts +14 -0
  96. package/src/ui/resetPasswordFlowStrings.ts +51 -7
  97. package/src/validation/fieldErrors.ts +4 -3
  98. package/src/validation/password.ts +5 -5
  99. package/src/validation/passwordRules.ts +2 -2
@@ -32,6 +32,7 @@ function read<T>(storageKey: string): T | null {
32
32
  }
33
33
  }
34
34
 
35
+ /** Options for `useFormDraft`. */
35
36
  export interface FormDraftOptions<T> {
36
37
  /**
37
38
  * The signed-in user's id. A browser profile can be shared, so drafts are stored per user. That
@@ -40,27 +41,28 @@ export interface FormDraftOptions<T> {
40
41
  * known.
41
42
  */
42
43
  owner: string | undefined;
43
- /** Identifies the form and the entity it edits, for example `recipe:new` or `recipe:42`. */
44
+ /** The key for the form and the entity it edits, for example `recipe:new` or `recipe:42`. */
44
45
  scope: string;
45
46
  /** The form's current values. They must survive `JSON.stringify`. */
46
47
  value: T;
47
48
  }
48
49
 
50
+ /** What `useFormDraft` returns. */
49
51
  export interface FormDraft<T> {
50
52
  /**
51
- * The stored draft, while it is still waiting to be restored or dismissed. Read once the owner is
52
- * known and again whenever the owner or scope changes, so it can appear after mount.
53
+ * The stored draft, while it is still waiting to be restored or dismissed. The hook reads it once
54
+ * the owner is known and again whenever the owner or scope changes, so it can appear after mount.
53
55
  */
54
56
  pending: T | null;
55
- /** Stop offering the draft. The caller applies the values it wants. */
57
+ /** Stops offering the draft. The caller applies the values it wants. */
56
58
  dismiss: () => void;
57
- /** Drop the stored draft after a successful save. */
59
+ /** Drops the stored draft. Call it after a successful save. */
58
60
  clear: () => void;
59
61
  }
60
62
 
61
63
  /**
62
- * Keeps a form's values in localStorage so a sign-out, reload or closed tab does not lose them.
63
- * Values are written half a second after the last change, so only that last half second is at risk.
64
+ * Keeps a form's values in localStorage so a sign-out, reload, or closed tab does not lose them. The
65
+ * hook writes them half a second after the last change, so only that last half second is at risk.
64
66
  *
65
67
  * A draft found at mount is offered for restore, not applied. So an edit form never silently
66
68
  * overwrites what the API returned. Saving starts at once, whether or not the offer is answered.
package/src/index.ts CHANGED
@@ -1,4 +1,2 @@
1
- export { defineSessionConfig, requireEnv, resolveJwtSecret } from "./config";
1
+ export { defineSessionConfig, requireEnv } from "./config";
2
2
  export type { SessionConfig } from "./config";
3
- export { formatApiError } from "./errors";
4
- export type { StatusMessages } from "./errors";
@@ -1,38 +1,24 @@
1
1
  import Link from "next/link";
2
- import type { LegalPage } from "../api/services";
2
+ import type { LegalPage } from "../api/legalPage";
3
3
  import { Markdown } from "../markdown";
4
4
  import { parseTimestamp } from "../seo/sitemap";
5
5
  import { withDefaults, type TextOverrides } from "../ui/withDefaults";
6
+ import { defaultLegalPageViewStrings, type LegalPageViewStrings } from "./legalPageViewStrings";
6
7
 
7
- /** The legal page's text around the content. Pass the app's own language. The defaults are English. */
8
- export interface LegalPageViewStrings {
9
- /** Before the date the page last changed. */
10
- updated: string;
11
- /** Shown in place of the content when nobody has written the page yet. */
12
- missing: string;
13
- /** The link back to the front page. */
14
- toFrontPage: string;
15
- }
16
-
17
- export const defaultLegalPageViewStrings: LegalPageViewStrings = {
18
- updated: "Last updated",
19
- missing: "This content has not been written yet.",
20
- toFrontPage: "Back to the front page",
21
- };
22
-
8
+ /** Props for `LegalPageView`. */
23
9
  export interface LegalPageViewProps {
24
10
  /** The page from the API, or null when nobody has written it yet. */
25
11
  page: LegalPage | null;
26
12
  /** The heading while there is no page. */
27
13
  title: string;
28
- /** The front page, for the link at the bottom. */
14
+ /** The front page's path, for the link at the bottom. */
29
15
  homeHref: string;
30
16
  /** The BCP 47 tag the date is written in, such as `nb-NO`. */
31
17
  dateLocale: string;
32
18
  strings?: TextOverrides<LegalPageViewStrings> | undefined;
33
19
  }
34
20
 
35
- /** A terms, privacy or cookies page as an admin wrote it: the title, when it last changed and the Markdown body. */
21
+ /** Renders a terms, privacy, or cookies page as an admin wrote it: the title, when it last changed, and the Markdown body. */
36
22
  export function LegalPageView({ page, title, homeHref, dateLocale, strings }: LegalPageViewProps) {
37
23
  const text = withDefaults(defaultLegalPageViewStrings, strings);
38
24
  // A malformed timestamp leaves the date out rather than failing the page.
@@ -1 +1,4 @@
1
- export { LegalPageView, defaultLegalPageViewStrings, type LegalPageViewProps, type LegalPageViewStrings } from "./LegalPageView";
1
+ export type { LegalPage } from "../api/legalPage";
2
+ export { LEGAL_PAGE_KEYS, type DefaultLegalPageKey } from "./keys";
3
+ export { LegalPageView, type LegalPageViewProps } from "./LegalPageView";
4
+ export { defaultLegalPageViewStrings, type LegalPageViewStrings } from "./legalPageViewStrings";
@@ -0,0 +1,10 @@
1
+ // Plain values with no imports, so the api, seo, admin, and legal entry points can share them.
2
+
3
+ /**
4
+ * The legal pages a site has when it picks none, the same default as the `Keys` option of
5
+ * SjolystInnovation.AppKit.SiteSettings's `MapAppKitLegalPages`.
6
+ */
7
+ export const LEGAL_PAGE_KEYS = ["terms", "privacy", "cookies"] as const;
8
+
9
+ /** A key of `LEGAL_PAGE_KEYS`. The legal page types and calls default to it. */
10
+ export type DefaultLegalPageKey = (typeof LEGAL_PAGE_KEYS)[number];
@@ -0,0 +1,17 @@
1
+ // Plain values, with no "use client", so a server component can import them.
2
+
3
+ /** The legal page's text around the content. Pass the app's own language. The defaults are English. */
4
+ export interface LegalPageViewStrings {
5
+ /** The text before the date the page last changed. */
6
+ updated: string;
7
+ /** The text shown in place of the content when nobody has written the page yet. */
8
+ missing: string;
9
+ /** The text of the link back to the front page. */
10
+ toFrontPage: string;
11
+ }
12
+
13
+ export const defaultLegalPageViewStrings: LegalPageViewStrings = {
14
+ updated: "Last updated",
15
+ missing: "This content has not been written yet.",
16
+ toFrontPage: "Back to the front page",
17
+ };
@@ -0,0 +1,330 @@
1
+ // Norwegian (bokmål) text for every app-kit component that takes `strings`. Plain values with type
2
+ // only imports, so this entry point needs no peer dependency and a server component can import it.
3
+ //
4
+ // The wording follows Norwegian public services. A field error names the field or the problem first,
5
+ // says what is wrong and how to fix it, as Skatteetaten does, and uses Altinn's phrasing such as
6
+ // "Du må fylle ut" and "Bruk {max} eller færre tegn".
7
+
8
+ import type { AdminUsersPanelStrings } from "../admin/adminUsersPanelStrings";
9
+ import type { CompanySettingsFormStrings } from "../admin/companySettingsFormStrings";
10
+ import type { EmailStatsPanelStrings } from "../admin/emailStatsPanelStrings";
11
+ import type { LegalPagesEditorStrings } from "../admin/legalPagesEditorStrings";
12
+ import type { StatHistoryChartStrings } from "../admin/statHistoryChartStrings";
13
+ import type { BrandingManagerStrings } from "../branding/brandingManagerStrings";
14
+ import type { DefaultLegalPageKey } from "../legal/keys";
15
+ import type { LegalPageViewStrings } from "../legal/legalPageViewStrings";
16
+ import type { SectionsEditorStrings } from "../sections/sectionsEditorStrings";
17
+ import type { SharedSectionType } from "../sections/types";
18
+ import type { CredentialsFormStrings } from "../session/react/credentialsFormStrings";
19
+ import type { LoginPageStrings } from "../session/react/loginPageStrings";
20
+ import type { SessionExpiryGuardStrings } from "../session/react/sessionExpiryGuardStrings";
21
+ import type { ErrorStateStrings } from "../ui/errorStateStrings";
22
+ import type { NotFoundStateStrings } from "../ui/notFoundStateStrings";
23
+ import type { ResetPasswordFlowStrings } from "../ui/resetPasswordFlowStrings";
24
+ import { PASSWORD_MIN_LENGTH, type PasswordStrings } from "../validation/passwordRules";
25
+
26
+ const required = "Du må fylle ut dette feltet.";
27
+ const tooLong = "Bruk {max} eller færre tegn.";
28
+ const passwordRules = `Bruk minst ${PASSWORD_MIN_LENGTH} tegn, med minst én liten og én stor bokstav fra A til Z og ett siffer fra 0 til 9.`;
29
+
30
+ /** For `passwordSchema` and the `passwordStrings` of `ResetPasswordFlow`. */
31
+ export const nbPasswordStrings: PasswordStrings = {
32
+ tooShort: `Passordet er for kort. Bruk minst ${PASSWORD_MIN_LENGTH} tegn.`,
33
+ lowercase: "Passordet mangler en liten bokstav. Ta med en bokstav fra a til z.",
34
+ uppercase: "Passordet mangler en stor bokstav. Ta med en bokstav fra A til Z.",
35
+ digit: "Passordet mangler et siffer. Ta med et siffer fra 0 til 9.",
36
+ };
37
+
38
+ /** For `CredentialsForm`, and the `formStrings` of `LoginPage` and `SessionExpiryGuard`. */
39
+ export const nbCredentialsFormStrings: CredentialsFormStrings = {
40
+ email: "E-post",
41
+ password: "Passord",
42
+ signIn: "Logg inn",
43
+ signingIn: "Logger inn…",
44
+ invalidCredentials: "Feil e-post eller passord.",
45
+ lockedOut: "Du har prøvd å logge inn for mange ganger, så innloggingen er låst en stund. Prøv igjen senere.",
46
+ signInUnavailable: "Innlogging er ikke tilgjengelig akkurat nå. Prøv igjen om litt.",
47
+ somethingWentWrong: "Noe gikk galt. Prøv igjen.",
48
+ showPassword: "Vis passord",
49
+ hidePassword: "Skjul passord",
50
+ };
51
+
52
+ /** For `LoginPage`. */
53
+ export const nbLoginPageStrings: LoginPageStrings = {
54
+ title: "Logg inn",
55
+ forgotPassword: "Glemt passord?",
56
+ registerPrompt: "Har du ikke konto?",
57
+ register: "Opprett konto",
58
+ };
59
+
60
+ /** For `SessionExpiryGuard`. */
61
+ export const nbSessionExpiryGuardStrings: SessionExpiryGuardStrings = {
62
+ expiredTitle: "Økten er utløpt",
63
+ expiredBody: "Økten din er utløpt. Logg inn på nytt for å lagre.",
64
+ expiringSoon: "Økten utløper om {minutes} min. Logg inn på nytt for å fortsette uten avbrudd.",
65
+ reSignIn: "Logg inn på nytt",
66
+ notRestored: "Vi fikk ikke fornyet økten. Prøv igjen.",
67
+ wrongUser: "Denne siden tilhører en annen bruker. Logg inn med samme konto.",
68
+ close: "Lukk",
69
+ };
70
+
71
+ /** For `ResetPasswordFlow`. Pass `nbPasswordStrings` as its `passwordStrings` too. */
72
+ export const nbResetPasswordFlowStrings: ResetPasswordFlowStrings = {
73
+ title: "Sett nytt passord",
74
+ signInPrompt: "Husker du passordet?",
75
+ signIn: "Logg inn",
76
+ backToSignIn: "Logg inn →",
77
+ intro: "Skriv inn e-postadressen din, så sender vi en kode.",
78
+ codeSent: "Sjekk e-posten din. Vi har sendt deg en kode.",
79
+ email: "E-post",
80
+ code: "Kode fra e-post",
81
+ newPassword: "Nytt passord",
82
+ passwordHint: passwordRules,
83
+ requestCode: "Send kode",
84
+ requestingCode: "Sender…",
85
+ setPassword: "Lagre passord",
86
+ settingPassword: "Lagrer…",
87
+ done: "Passordet er endret. Du kan logge inn nå.",
88
+ passwordInvalid: `Passordet oppfyller ikke kravene. ${passwordRules}`,
89
+ somethingWentWrong: "Noe gikk galt. Prøv igjen.",
90
+ validation: {
91
+ required,
92
+ invalidEmail: "E-postadressen er ugyldig. Skriv den slik: navn@domene.no.",
93
+ passwordTooShort: "Passordet er for kort. Bruk minst {max} tegn.",
94
+ passwordRequiresDigit: "Passordet mangler et siffer. Ta med et siffer fra 0 til 9.",
95
+ passwordRequiresLower: "Passordet mangler en liten bokstav. Ta med en bokstav fra a til z.",
96
+ passwordRequiresUpper: "Passordet mangler en stor bokstav. Ta med en bokstav fra A til Z.",
97
+ passwordRequiresNonAlphanumeric: "Passordet mangler et spesialtegn. Ta med et tegn som ikke er en bokstav eller et siffer, for eksempel ! eller #.",
98
+ },
99
+ errors: {
100
+ invalidResetCode: "Koden er feil eller har gått ut. Sjekk koden i e-posten, eller be om en ny kode.",
101
+ tooManyResetAttempts: "Du har skrevet feil kode for mange ganger. Be om en ny kode.",
102
+ },
103
+ };
104
+
105
+ /** For `ErrorState`. */
106
+ export const nbErrorStateStrings: ErrorStateStrings = {
107
+ title: "Midlertidig utilgjengelig",
108
+ body: "Vi får ikke kontakt med tjenesten akkurat nå. Prøv igjen om litt.",
109
+ tryAgain: "Prøv igjen",
110
+ home: "Til forsiden",
111
+ };
112
+
113
+ /** For `NotFoundState`. */
114
+ export const nbNotFoundStateStrings: NotFoundStateStrings = {
115
+ code: "404",
116
+ body: "Siden finnes ikke, eller den er flyttet.",
117
+ home: "Til forsiden",
118
+ };
119
+
120
+ /** For `CompanySettingsForm`. */
121
+ export const nbCompanySettingsFormStrings: CompanySettingsFormStrings = {
122
+ contactRecipient: "Mottaker for henvendelser",
123
+ contactRecipientHint: "Meldinger fra kontaktskjemaet sendes til denne adressen.",
124
+ companyName: "Firmanavn",
125
+ legalName: "Registrert navn",
126
+ legalNameHint: "Navnet i Enhetsregisteret. La feltet stå tomt for å bruke firmanavnet.",
127
+ orgNumber: "Organisasjonsnummer",
128
+ vatRegistered: "Registrert i Merverdiavgiftsregisteret",
129
+ vatHint: "Krever organisasjonsnummer. Når dette er slått på, vises nummeret med «MVA».",
130
+ streetAddress: "Gateadresse",
131
+ postalCode: "Postnummer",
132
+ addressLocality: "Poststed",
133
+ addressRegion: "Fylke",
134
+ addressHint: "Postnummer og poststed holdes atskilt slik at søkemotorer kan lese adressen. Fylke er valgfritt.",
135
+ publicEmail: "E-post som vises på nettsiden",
136
+ publicPhone: "Telefon som vises på nettsiden",
137
+ loading: "Laster…",
138
+ save: "Lagre",
139
+ saving: "Lagrer…",
140
+ loadFailed: "Kunne ikke laste innstillingene",
141
+ saved: "Innstillingene er lagret",
142
+ saveFailed: "Kunne ikke lagre innstillingene",
143
+ validation: {
144
+ required,
145
+ tooLong,
146
+ invalidEmail: "E-postadressen er ugyldig. Skriv den slik: navn@domene.no.",
147
+ orgNumberRequiredForVat: "Du må fylle ut organisasjonsnummer når virksomheten er registrert i Merverdiavgiftsregisteret.",
148
+ invalidOrgNumber: "Organisasjonsnummeret er ugyldig. Et organisasjonsnummer består av 9 siffer.",
149
+ invalidPostalCode: "Postnummeret er ugyldig. Et postnummer består av 4 siffer.",
150
+ placeRequired: "Du må fylle ut poststed når du har fylt ut postnummer.",
151
+ },
152
+ };
153
+
154
+ /** For `AdminUsersPanel`. */
155
+ export const nbAdminUsersPanelStrings: AdminUsersPanelStrings = {
156
+ users: "Brukere",
157
+ showDeleted: "Vis slettede",
158
+ inviteUser: "Inviter bruker",
159
+ firstName: "Fornavn",
160
+ lastName: "Etternavn",
161
+ email: "E-post",
162
+ role: "Rolle",
163
+ sendInvite: "Send invitasjon",
164
+ sending: "Sender…",
165
+ inviteHint: "Brukeren får en e-post med en kode og setter passordet selv på siden for glemt passord.",
166
+ loading: "Laster…",
167
+ deleted: "slettet",
168
+ delete: "Slett",
169
+ remove: "Fjern",
170
+ noRoles: "Ingen roller",
171
+ addRole: "Legg til rolle",
172
+ add: "Legg til",
173
+ cancel: "Avbryt",
174
+ usersLoadFailed: "Kunne ikke laste brukerne",
175
+ rolesLoadFailed: "Kunne ikke laste rollene",
176
+ roleAssigned: "{name} har fått rollen {role}",
177
+ roleAssignFailed: "Kunne ikke tildele rollen",
178
+ roleRemoved: "{role} er fjernet fra {name}",
179
+ roleRemoveFailed: "Kunne ikke fjerne rollen",
180
+ confirmDelete: "Slette {name}? Navn, e-post og passord blir fjernet, og brukeren kan ikke logge inn igjen.",
181
+ userDeleted: "Brukeren er slettet",
182
+ userDeleteFailed: "Kunne ikke slette brukeren",
183
+ inviteIncomplete: "Du må fylle ut navn og e-post.",
184
+ inviteSent: "Invitasjonen er sendt. Brukeren får en kode for å sette passord.",
185
+ inviteFailed: "Kunne ikke sende invitasjonen",
186
+ validation: {
187
+ required,
188
+ tooLong,
189
+ invalidEmail: "E-postadressen er ugyldig. Skriv den slik: navn@domene.no.",
190
+ unknownRole: "Rollen finnes ikke. Velg en rolle fra listen.",
191
+ emailTaken: "E-postadressen har allerede en konto. Bruk en annen e-postadresse.",
192
+ },
193
+ errors: {
194
+ unknownRole: "Rollen finnes ikke. Velg en rolle fra listen.",
195
+ ownAdminRole: "Du kan ikke fjerne din egen administratorrolle. Be en annen administrator om å gjøre det.",
196
+ lastAdmin: "Den siste administratoren må beholde administratorrollen. Gi rollen til noen andre først.",
197
+ roleAssignFailed: "Rollen ble ikke tildelt. Prøv igjen.",
198
+ roleRemoveFailed: "Rollen ble ikke fjernet. Prøv igjen.",
199
+ emailTaken: "E-postadressen har allerede en konto. Bruk en annen e-postadresse.",
200
+ },
201
+ deleteErrors: {
202
+ lastAdmin: "Den siste administratoren kan ikke slettes. Gi administratorrollen til noen andre først.",
203
+ accountChangeConflict: "Noen andre endret brukerkontoene samtidig. Prøv igjen.",
204
+ },
205
+ };
206
+
207
+ /** For `LegalPagesEditor`. */
208
+ export const nbLegalPagesEditorStrings: LegalPagesEditorStrings = {
209
+ heading: "Juridiske sider",
210
+ title: "Tittel",
211
+ bodyMarkdown: "Innhold (Markdown)",
212
+ notFilled: "Ikke fylt ut",
213
+ loading: "Laster…",
214
+ save: "Lagre",
215
+ saving: "Lagrer…",
216
+ loadFailed: "Kunne ikke laste sidene",
217
+ required: "Du må fylle ut tittel og innhold.",
218
+ saved: "Siden er lagret",
219
+ saveFailed: "Kunne ikke lagre siden",
220
+ validation: { required, tooLong },
221
+ };
222
+
223
+ /** The tab labels of the default legal pages, for the `keyLabels` of `LegalPagesEditor`. */
224
+ export const nbLegalPageKeyLabels: Record<DefaultLegalPageKey, string> = {
225
+ terms: "Vilkår",
226
+ privacy: "Personvern",
227
+ cookies: "Informasjonskapsler",
228
+ };
229
+
230
+ /** For `StatHistoryChart`. */
231
+ export const nbStatHistoryChartStrings: StatHistoryChartStrings = {
232
+ empty: "Ingen historikk ennå. Se innom i morgen.",
233
+ chart: "Statistikk over tid",
234
+ };
235
+
236
+ /** For `EmailStatsPanel`. */
237
+ export const nbEmailStatsPanelStrings: EmailStatsPanelStrings = {
238
+ title: "E-post (siste {days} dager)",
239
+ sent: "Sendt",
240
+ delivered: "Levert",
241
+ bounces: "Returnert",
242
+ blocked: "Blokkert eller spam",
243
+ loading: "Laster…",
244
+ unavailable: "E-poststatistikken er ikke tilgjengelig akkurat nå. Prøv igjen senere.",
245
+ };
246
+
247
+ /** For `BrandingManager`. */
248
+ export const nbBrandingManagerStrings: BrandingManagerStrings = {
249
+ title: "Logo og ikon",
250
+ logo: "Logo",
251
+ logoHint: "Vises i menylinjen og bunnteksten. PNG, JPEG, WebP eller GIF, maks 2 MB.",
252
+ icon: "Ikon (favicon)",
253
+ iconHint: "Ikonet i nettleserfanen. Kvadratisk PNG eller ICO, maks 2 MB.",
254
+ none: "ingen",
255
+ choose: "Velg fil",
256
+ remove: "Fjern",
257
+ save: "Lagre logo og ikon",
258
+ saving: "Lagrer…",
259
+ saved: "Logo og ikon er lagret",
260
+ saveFailed: "Kunne ikke lagre logo og ikon",
261
+ validation: {
262
+ invalidImage: "Ugyldig filformat. Last opp en bildefil.",
263
+ imageTooLarge: "Filen er for stor. Last opp en fil på 2 MB eller mindre.",
264
+ },
265
+ };
266
+
267
+ /** For `LegalPageView`. */
268
+ export const nbLegalPageViewStrings: LegalPageViewStrings = {
269
+ updated: "Sist oppdatert",
270
+ missing: "Dette innholdet er ikke lagt inn ennå.",
271
+ toFrontPage: "Til forsiden",
272
+ };
273
+
274
+ /** For `SectionsEditor` and the section editors. */
275
+ export const nbSectionsEditorStrings: SectionsEditorStrings = {
276
+ hide: "Skjul",
277
+ show: "Vis",
278
+ moveUp: "Flytt opp",
279
+ moveDown: "Flytt ned",
280
+ duplicate: "Dupliser",
281
+ remove: "Slett",
282
+ addSection: "Legg til seksjon",
283
+ heading: "Overskrift",
284
+ headingOptional: "Overskrift (valgfri)",
285
+ subheading: "Undertekst",
286
+ text: "Tekst",
287
+ body: "Brødtekst (Markdown)",
288
+ primaryButtonLabel: "Tekst på knapp 1",
289
+ primaryButtonHref: "Lenke på knapp 1",
290
+ secondaryButtonLabel: "Tekst på knapp 2",
291
+ secondaryButtonHref: "Lenke på knapp 2",
292
+ buttonLabel: "Tekst på knappen",
293
+ buttonHref: "Lenke på knappen",
294
+ bullets: "Punkter",
295
+ addBullet: "Legg til punkt",
296
+ count: "Antall som vises",
297
+ stats: "Tall",
298
+ source: "Kilde",
299
+ sourceStatic: "Fast tall",
300
+ value: "Verdi",
301
+ label: "Tekst",
302
+ addStat: "Legg til tall",
303
+ layout: "Oppsett",
304
+ layoutStandard: "Standard (midtstilt)",
305
+ layoutFull: "Full bredde",
306
+ layoutLeft: "Bilde til venstre, tekst til høyre",
307
+ layoutRight: "Bilde til høyre, tekst til venstre",
308
+ layoutOverlay: "Tekst over bildet",
309
+ layoutOverlayFull: "Tekst over bildet (full bredde)",
310
+ altText: "Alternativ tekst",
311
+ caption: "Bildetekst (valgfri)",
312
+ image: "Bilde",
313
+ uploadImage: "Last opp bilde",
314
+ replaceImage: "Bytt bilde",
315
+ uploading: "Laster opp…",
316
+ removeImage: "Fjern",
317
+ uploadFailed: "Kunne ikke laste opp bildet",
318
+ };
319
+
320
+ /** For the `typeLabels` of `SectionsEditor`. */
321
+ export const nbSectionTypeLabels: Record<SharedSectionType, string> = {
322
+ hero: "Toppfelt",
323
+ feature: "Punktliste",
324
+ text: "Tekst",
325
+ feed: "Siste oppføringer",
326
+ contact: "Kontaktskjema",
327
+ cta: "Handlingsfelt",
328
+ stats: "Tallrad",
329
+ image: "Bilde",
330
+ };
@@ -5,20 +5,20 @@ import remarkGfm from "remark-gfm";
5
5
 
6
6
  type Tag = keyof React.JSX.IntrinsicElements;
7
7
 
8
- /** The props without the `node` react-markdown passes, which is no DOM attribute. */
8
+ /** Copies the props without the `node` that react-markdown passes, since `node` is no DOM attribute. */
9
9
  function domProps<P extends ExtraProps>(props: P): Omit<P, "node"> {
10
10
  const rest = { ...props };
11
11
  delete rest.node;
12
12
  return rest;
13
13
  }
14
14
 
15
- /** An app's element, given the props without `node`, so it can spread them onto a DOM element. */
15
+ /** Wraps an app's element so it gets the props without `node` and can spread them onto a DOM element. */
16
16
  function withoutNode(Element: ElementType) {
17
17
  const Wrapped = (props: ExtraProps) => <Element {...domProps(props)} />;
18
18
  return Wrapped;
19
19
  }
20
20
 
21
- /** An element with fixed classes. */
21
+ /** Creates an element with fixed classes. */
22
22
  function styled<T extends Tag>(Element: T, className: string) {
23
23
  const Styled = (props: ComponentPropsWithoutRef<T> & ExtraProps) => {
24
24
  const Component = Element as ElementType;
@@ -27,7 +27,7 @@ function styled<T extends Tag>(Element: T, className: string) {
27
27
  return Styled;
28
28
  }
29
29
 
30
- /** The default look. A heading level one becomes a level two, since the page has its own `h1`. */
30
+ /** The default elements. A level one heading becomes a level two heading, since the page has its own `h1`. */
31
31
  export const markdownComponents: Components = {
32
32
  h1: styled("h2", "text-lg font-semibold text-markdown-heading"),
33
33
  h2: styled("h2", "text-base font-semibold text-markdown-heading pt-2"),
@@ -54,20 +54,22 @@ export const markdownComponents: Components = {
54
54
 
55
55
  const isExternal = (href: string | undefined) => href != null && /^https?:\/\//i.test(href);
56
56
 
57
+ /** Props for `Markdown`. */
57
58
  export interface MarkdownProps {
58
- /** Markdown text. Empty, null or undefined renders nothing. */
59
+ /** The Markdown text. An empty string, null, or undefined renders nothing. */
59
60
  children: string | null | undefined;
60
- /** Replaces the default element for a tag, such as `{ p: MyParagraph }`. It gets the props without `node`. */
61
+ /** The elements that replace the default element for a tag, such as `{ p: MyParagraph }`. Each gets the props without `node`. */
61
62
  components?: Components | undefined;
62
- /** Opens links to other sites in a new tab, without passing the opener or referrer. */
63
+ /** Whether links to other sites open in a new tab, without passing the opener or referrer. Defaults to `false`. */
63
64
  externalLinks?: boolean | undefined;
64
- /** Wraps the output in a `div` with these classes. Without it the elements render bare, so a parent's `space-y-*` spaces them. */
65
+ /** The classes for a `div` around the output. Without it, the elements render bare, so a parent's `space-y-*` spaces them. */
65
66
  className?: string | undefined;
66
67
  }
67
68
 
68
69
  /**
69
- * Markdown from an editor or the API, with GitHub tables, lists and links. Raw HTML is never
70
- * rendered, and the output is sanitized, so the text cannot inject scripts, styles or event handlers.
70
+ * Renders Markdown from an editor or the API, with GitHub tables, lists, and links. Raw HTML is
71
+ * never rendered, and the output is sanitized, so the text cannot inject scripts, styles, or event
72
+ * handlers.
71
73
  */
72
74
  export function Markdown({ children, components, externalLinks = false, className }: MarkdownProps) {
73
75
  if (!children) return null;
@@ -1 +1 @@
1
- export { Markdown, markdownComponents, type MarkdownProps } from "./Markdown";
1
+ export { Markdown, type MarkdownProps } from "./Markdown";
@@ -2,23 +2,25 @@
2
2
  // them with `import "@sjolystinnovation/app-kit/next-auth";` in one of its own .d.ts files.
3
3
  import "next-auth";
4
4
  import "next-auth/jwt";
5
+ import type { SessionUser } from "./auth/createAuthOptions";
6
+ import type { SessionError } from "./session/expiry";
5
7
 
6
8
  declare module "next-auth" {
7
9
  interface Session {
8
- user: { id: string; name: string; email: string; roles: string[] };
10
+ user: SessionUser;
9
11
  accessToken: string;
10
- error?: string | undefined;
11
- absoluteExpiresAt?: number;
12
+ error?: SessionError | undefined;
13
+ absoluteExpiresAt?: number | undefined;
12
14
  }
13
15
  }
14
16
 
15
17
  declare module "next-auth/jwt" {
16
18
  interface JWT {
17
- accessToken?: string;
18
- refreshToken?: string;
19
- refreshAt?: number;
20
- loginAt?: number;
21
- user?: { id: string; name: string; email: string; roles: string[] };
22
- error?: string | undefined;
19
+ accessToken?: string | undefined;
20
+ refreshToken?: string | undefined;
21
+ refreshAt?: number | undefined;
22
+ loginAt?: number | undefined;
23
+ user?: SessionUser | undefined;
24
+ error?: SessionError | undefined;
23
25
  }
24
26
  }
@@ -0,0 +1,96 @@
1
+ /** A response header, as `next.config.ts` lists them. */
2
+ export interface Header {
3
+ key: string;
4
+ value: string;
5
+ }
6
+
7
+ /** Gets the API's origin, such as `https://api.example.com`. Returns `""` when the URL is missing or invalid. */
8
+ export function apiOrigin(apiUrl: string | undefined): string {
9
+ if (!apiUrl) return "";
10
+ try {
11
+ return new URL(apiUrl).origin;
12
+ } catch {
13
+ return "";
14
+ }
15
+ }
16
+
17
+ /** Gets the API's base URL with its path, such as `https://api.example.com/api`, without a trailing slash. Returns `""` when the URL is missing or invalid. */
18
+ export function apiBaseUrl(apiUrl: string | undefined): string {
19
+ if (!apiUrl) return "";
20
+ try {
21
+ return new URL(apiUrl).toString().replace(/\/$/, "");
22
+ } catch {
23
+ return "";
24
+ }
25
+ }
26
+
27
+ /** The Content-Security-Policy options for `defineAppConfig`. */
28
+ export interface CspOptions {
29
+ /** The app's API URL, usually `process.env.NEXT_PUBLIC_API_URL`. The policy allows fetches to its origin. */
30
+ apiUrl: string | undefined;
31
+ /** Whether this is a development build. A development build also allows `'unsafe-eval'`, which hot reload needs. */
32
+ dev: boolean;
33
+ /** Whether to also allow `ws:` and `wss:` to the API, for a WebSocket such as SignalR. */
34
+ websocket?: boolean | undefined;
35
+ /** Whether to also allow images from the API origin. You do not need it when images go through the content images rewrite. */
36
+ imgFromApi?: boolean | undefined;
37
+ /** The extra sources for `connect-src`. */
38
+ connectSrc?: readonly string[] | undefined;
39
+ /** The extra sources for `img-src`. */
40
+ imgSrc?: readonly string[] | undefined;
41
+ /** The sources for `frame-src`. Defaults to `'none'`, since no page embeds a frame. */
42
+ frameSrc?: readonly string[] | undefined;
43
+ /** The sources for `object-src`. Defaults to `'none'`, since no page embeds an object. */
44
+ objectSrc?: readonly string[] | undefined;
45
+ }
46
+
47
+ const sources = (list: readonly (string | false | undefined)[]) => list.filter(Boolean).join(" ");
48
+
49
+ /**
50
+ * Builds the Content-Security-Policy for an app's pages. Scripts allow `'unsafe-inline'`, because a
51
+ * nonce would make every statically rendered page dynamic.
52
+ */
53
+ export function contentSecurityPolicy(o: CspOptions): string {
54
+ const origin = apiOrigin(o.apiUrl);
55
+ const wsOrigin = o.websocket ? origin.replace(/^https:/, "wss:").replace(/^http:/, "ws:") : "";
56
+ return [
57
+ "default-src 'self'",
58
+ o.dev ? "script-src 'self' 'unsafe-eval' 'unsafe-inline'" : "script-src 'self' 'unsafe-inline'",
59
+ "style-src 'self' 'unsafe-inline'",
60
+ `img-src ${sources(["'self'", o.imgFromApi && origin, "data:", "blob:", ...(o.imgSrc ?? [])])}`,
61
+ `connect-src ${sources(["'self'", origin, wsOrigin, ...(o.connectSrc ?? [])])}`,
62
+ `frame-src ${sources(o.frameSrc ?? ["'none'"])}`,
63
+ `object-src ${sources(o.objectSrc ?? ["'none'"])}`,
64
+ "font-src 'self'",
65
+ "base-uri 'self'",
66
+ "form-action 'self'",
67
+ "frame-ancestors 'none'",
68
+ "upgrade-insecure-requests",
69
+ ].join("; ");
70
+ }
71
+
72
+ /** Builds the security headers for an app's pages, including the Content-Security-Policy. */
73
+ export function securityHeaders(o: CspOptions & { permissionsPolicy?: string | undefined }): Header[] {
74
+ return [
75
+ { key: "X-Content-Type-Options", value: "nosniff" },
76
+ { key: "X-Frame-Options", value: "DENY" },
77
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
78
+ { key: "X-DNS-Prefetch-Control", value: "on" },
79
+ { key: "Permissions-Policy", value: o.permissionsPolicy ?? "camera=(), microphone=(), geolocation=()" },
80
+ { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
81
+ { key: "Content-Security-Policy", value: contentSecurityPolicy(o) },
82
+ ];
83
+ }
84
+
85
+ /**
86
+ * Builds the rewrite that proxies content images, so the browser never contacts the API host. It is
87
+ * a rewrite rather than a route handler, so the query string and the Accept header, which webp
88
+ * needs, pass through. Returns no rewrite when the API URL is missing or invalid.
89
+ */
90
+ export function contentImagesRewrite(apiUrl: string | undefined, o: { source?: string | undefined; upstreamPath?: string | undefined } = {}) {
91
+ const base = apiBaseUrl(apiUrl);
92
+ if (!base) return [];
93
+ const source = o.source ?? "/content-images";
94
+ const upstream = o.upstreamPath ?? "/content-images";
95
+ return [{ source: `${source}/:path*`, destination: `${base}${upstream}/:path*` }];
96
+ }