@sjolystinnovation/app-kit 0.5.0 → 0.7.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.
- package/README.md +219 -43
- package/package.json +11 -2
- package/src/api/index.ts +1 -0
- package/src/api/requestRevalidate.ts +15 -0
- package/src/auth/createAuthOptions.ts +1 -6
- package/src/server/index.ts +4 -0
- package/src/server/publicApi.ts +104 -0
- package/src/server/revalidateRoute.ts +77 -0
- package/src/session/expiry.ts +6 -0
- package/src/session/react/AppSessionProvider.tsx +23 -0
- package/src/session/react/ProtectedGate.tsx +34 -0
- package/src/session/react/RoleGate.tsx +66 -0
- package/src/session/react/SessionExpiryGuard.tsx +294 -0
- package/src/session/react/index.ts +10 -0
- package/src/session/react/sessionExpiryGuardStrings.ts +26 -0
- package/src/styles.css +16 -3
- package/src/toast/index.ts +11 -0
- package/src/ui/Alert.tsx +4 -3
- package/src/ui/CredentialsForm.tsx +113 -0
- package/src/ui/PasswordInput.tsx +11 -29
- package/src/ui/TextInput.tsx +28 -0
- package/src/ui/credentialsFormStrings.ts +29 -0
- package/src/ui/field.tsx +51 -0
- package/src/ui/index.ts +7 -0
- package/src/ui/withDefaults.ts +14 -0
package/src/ui/PasswordInput.tsx
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/outline";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { useState, type InputHTMLAttributes } from "react";
|
|
5
|
+
import { FieldError, FieldLabel, inputClass, useField } from "./field";
|
|
6
6
|
|
|
7
7
|
/** The input's type is left out: the component switches it to show and hide the value. */
|
|
8
8
|
export interface PasswordInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
|
9
9
|
label: string;
|
|
10
|
-
error?: string;
|
|
10
|
+
error?: string | undefined;
|
|
11
11
|
/** Accessible name of the toggle while the password is hidden. */
|
|
12
|
-
showPasswordLabel?: string;
|
|
12
|
+
showPasswordLabel?: string | undefined;
|
|
13
13
|
/** Accessible name of the toggle while the password is visible. */
|
|
14
|
-
hidePasswordLabel?: string;
|
|
14
|
+
hidePasswordLabel?: string | undefined;
|
|
15
15
|
/**
|
|
16
16
|
* Classes for placing the field, such as a margin. They go on the outer element, which holds the
|
|
17
17
|
* label, the input and the error. Restyle the field through the theme variables.
|
|
@@ -22,39 +22,25 @@ export interface PasswordInputProps extends Omit<InputHTMLAttributes<HTMLInputEl
|
|
|
22
22
|
export function PasswordInput({
|
|
23
23
|
label,
|
|
24
24
|
error,
|
|
25
|
-
id,
|
|
26
|
-
required,
|
|
27
25
|
className,
|
|
28
26
|
showPasswordLabel = "Show password",
|
|
29
27
|
hidePasswordLabel = "Hide password",
|
|
30
28
|
...props
|
|
31
29
|
}: PasswordInputProps) {
|
|
32
30
|
const [visible, setVisible] = useState(false);
|
|
33
|
-
|
|
34
|
-
const generated = useId();
|
|
35
|
-
const inputId = id ?? generated;
|
|
36
|
-
// From useId, which has no spaces. aria-describedby splits its ids on spaces.
|
|
37
|
-
const errorId = `${generated}-error`;
|
|
31
|
+
const field = useField(props, error);
|
|
38
32
|
|
|
39
33
|
return (
|
|
40
34
|
<div className={className}>
|
|
41
|
-
<
|
|
35
|
+
<FieldLabel htmlFor={field.inputId} required={props.required}>
|
|
42
36
|
{label}
|
|
43
|
-
|
|
44
|
-
</label>
|
|
37
|
+
</FieldLabel>
|
|
45
38
|
<div className="relative">
|
|
46
39
|
<input
|
|
47
40
|
{...props}
|
|
48
|
-
|
|
41
|
+
{...field.inputProps}
|
|
49
42
|
type={visible ? "text" : "password"}
|
|
50
|
-
|
|
51
|
-
aria-required={required ? true : props["aria-required"]}
|
|
52
|
-
aria-invalid={error ? true : props["aria-invalid"]}
|
|
53
|
-
aria-describedby={cx(props["aria-describedby"], error && errorId) || undefined}
|
|
54
|
-
className={cx(
|
|
55
|
-
"w-full rounded-input border px-3 py-input-y pr-10 text-sm bg-input-bg text-input-text placeholder-input-placeholder focus:outline-hidden focus:ring-(length:--input-focus-ring-width) focus:ring-input-focus-ring",
|
|
56
|
-
error ? "border-input-border-error" : "border-input-border focus:border-input-focus-border",
|
|
57
|
-
)}
|
|
43
|
+
className={inputClass(error, "pr-10")}
|
|
58
44
|
/>
|
|
59
45
|
<button
|
|
60
46
|
type="button"
|
|
@@ -65,11 +51,7 @@ export function PasswordInput({
|
|
|
65
51
|
{visible ? <EyeSlashIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
|
|
66
52
|
</button>
|
|
67
53
|
</div>
|
|
68
|
-
{error
|
|
69
|
-
<p id={errorId} className="mt-1 text-xs text-field-error">
|
|
70
|
-
{error}
|
|
71
|
-
</p>
|
|
72
|
-
)}
|
|
54
|
+
<FieldError id={field.errorId} error={error} />
|
|
73
55
|
</div>
|
|
74
56
|
);
|
|
75
57
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { InputHTMLAttributes } from "react";
|
|
4
|
+
import { FieldError, FieldLabel, inputClass, useField } from "./field";
|
|
5
|
+
|
|
6
|
+
export interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
7
|
+
label: string;
|
|
8
|
+
error?: string | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* Classes for placing the field, such as a margin. They go on the outer element, which holds the
|
|
11
|
+
* label, the input and the error. Restyle the field through the theme variables.
|
|
12
|
+
*/
|
|
13
|
+
className?: string | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function TextInput({ label, error, className, ...props }: TextInputProps) {
|
|
17
|
+
const field = useField(props, error);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className={className}>
|
|
21
|
+
<FieldLabel htmlFor={field.inputId} required={props.required}>
|
|
22
|
+
{label}
|
|
23
|
+
</FieldLabel>
|
|
24
|
+
<input {...props} {...field.inputProps} className={inputClass(error)} />
|
|
25
|
+
<FieldError id={field.errorId} error={error} />
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Plain values, with no "use client", so a server component can import them.
|
|
2
|
+
|
|
3
|
+
/** Thrown from `onSignedIn` to refuse a sign-in. Its message is shown in the form. */
|
|
4
|
+
export class SignInRejected extends Error {}
|
|
5
|
+
|
|
6
|
+
/** The form's text. Pass the app's own language. The defaults are English. */
|
|
7
|
+
export interface CredentialsFormStrings {
|
|
8
|
+
email: string;
|
|
9
|
+
password: string;
|
|
10
|
+
signIn: string;
|
|
11
|
+
signingIn: string;
|
|
12
|
+
invalidCredentials: string;
|
|
13
|
+
signInUnavailable: string;
|
|
14
|
+
somethingWentWrong: string;
|
|
15
|
+
showPassword: string;
|
|
16
|
+
hidePassword: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const defaultCredentialsFormStrings: CredentialsFormStrings = {
|
|
20
|
+
email: "Email",
|
|
21
|
+
password: "Password",
|
|
22
|
+
signIn: "Sign in",
|
|
23
|
+
signingIn: "Signing in…",
|
|
24
|
+
invalidCredentials: "Invalid email or password.",
|
|
25
|
+
signInUnavailable: "Sign-in is unavailable right now. Try again shortly.",
|
|
26
|
+
somethingWentWrong: "Something went wrong",
|
|
27
|
+
showPassword: "Show password",
|
|
28
|
+
hidePassword: "Hide password",
|
|
29
|
+
};
|
package/src/ui/field.tsx
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useId, type InputHTMLAttributes, type ReactNode } from "react";
|
|
4
|
+
import { cx } from "./cx";
|
|
5
|
+
|
|
6
|
+
/** The ids and aria attributes a labelled input needs, from the caller's own props. */
|
|
7
|
+
export function useField(props: Pick<InputHTMLAttributes<HTMLInputElement>, "id" | "required" | "aria-required" | "aria-invalid" | "aria-describedby">, error: string | undefined) {
|
|
8
|
+
// A generated id ties the label to its own control, even when two fields on a page share a name.
|
|
9
|
+
const generated = useId();
|
|
10
|
+
const inputId = props.id ?? generated;
|
|
11
|
+
// From useId, which has no spaces. aria-describedby splits its ids on spaces.
|
|
12
|
+
const errorId = `${generated}-error`;
|
|
13
|
+
return {
|
|
14
|
+
inputId,
|
|
15
|
+
errorId,
|
|
16
|
+
inputProps: {
|
|
17
|
+
id: inputId,
|
|
18
|
+
required: props.required,
|
|
19
|
+
"aria-required": props.required ? true : props["aria-required"],
|
|
20
|
+
"aria-invalid": error ? true : props["aria-invalid"],
|
|
21
|
+
"aria-describedby": cx(props["aria-describedby"], error && errorId) || undefined,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The input's look, with the error border when there is an error. */
|
|
27
|
+
export function inputClass(error: string | undefined, extra?: string): string {
|
|
28
|
+
return cx(
|
|
29
|
+
"w-full rounded-input border px-3 py-input-y text-sm bg-input-bg text-input-text placeholder-input-placeholder focus:outline-hidden focus:ring-(length:--input-focus-ring-width) focus:ring-input-focus-ring",
|
|
30
|
+
extra,
|
|
31
|
+
error ? "border-input-border-error" : "border-input-border focus:border-input-focus-border",
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function FieldLabel({ htmlFor, required, children }: { htmlFor: string; required: boolean | undefined; children: ReactNode }) {
|
|
36
|
+
return (
|
|
37
|
+
<label htmlFor={htmlFor} className="block text-field-label font-medium text-field-label-text mb-field-label">
|
|
38
|
+
{children}
|
|
39
|
+
{required && <span className="text-field-required" aria-hidden="true"> *</span>}
|
|
40
|
+
</label>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function FieldError({ id, error }: { id: string; error: string | undefined }) {
|
|
45
|
+
if (!error) return null;
|
|
46
|
+
return (
|
|
47
|
+
<p id={id} className="mt-1 text-xs text-field-error">
|
|
48
|
+
{error}
|
|
49
|
+
</p>
|
|
50
|
+
);
|
|
51
|
+
}
|
package/src/ui/index.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
export { Alert } from "./Alert";
|
|
2
2
|
export type { AlertProps } from "./Alert";
|
|
3
|
+
export { CredentialsForm } from "./CredentialsForm";
|
|
4
|
+
export type { CredentialsFormProps } from "./CredentialsForm";
|
|
5
|
+
export { SignInRejected, defaultCredentialsFormStrings } from "./credentialsFormStrings";
|
|
6
|
+
export type { CredentialsFormStrings } from "./credentialsFormStrings";
|
|
3
7
|
export { PasswordInput } from "./PasswordInput";
|
|
4
8
|
export type { PasswordInputProps } from "./PasswordInput";
|
|
9
|
+
export { TextInput } from "./TextInput";
|
|
10
|
+
export type { TextInputProps } from "./TextInput";
|
|
11
|
+
export type { TextOverrides } from "./withDefaults";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Text overrides where any key may be missing or undefined. */
|
|
2
|
+
export type TextOverrides<T> = { [K in keyof T]?: T[K] | undefined };
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fills in defaults for any string that is missing or undefined, so a key missing from an app's
|
|
6
|
+
* dictionary still shows the default text.
|
|
7
|
+
*/
|
|
8
|
+
export function withDefaults<T extends object>(defaults: T, overrides: TextOverrides<T> | undefined): T {
|
|
9
|
+
const result = { ...defaults };
|
|
10
|
+
for (const [key, value] of Object.entries(overrides ?? {})) {
|
|
11
|
+
if (value !== undefined) (result as Record<string, unknown>)[key] = value;
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
}
|