@yackey-labs/yauth-ui-solidjs 0.12.3 → 0.12.4

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.
@@ -0,0 +1,121 @@
1
+ import type { CreateDomainResponse } from "@yackey-labs/yauth-client";
2
+ import { type Component, createSignal, Show } from "solid-js";
3
+ import { createDomains } from "../hooks/create-organizations";
4
+
5
+ export interface DomainClaimProps {
6
+ organizationId: string;
7
+ onSuccess?: (result: CreateDomainResponse) => void;
8
+ onError?: (error: Error) => void;
9
+ }
10
+
11
+ const isDomain = (v: string) => /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(v.trim());
12
+
13
+ /**
14
+ * Form for an org admin to claim a new email domain. On success surfaces
15
+ * the verification token + the DNS record name; the parent UI is expected
16
+ * to chain into <DomainVerifyStep /> for the "publish the TXT record" step.
17
+ */
18
+ export const DomainClaim: Component<DomainClaimProps> = (props) => {
19
+ const { claim, lastCreated, error, submitting } = createDomains(() => props.organizationId);
20
+ const [domain, setDomain] = createSignal("");
21
+ const [autoJoin, setAutoJoin] = createSignal(false);
22
+ const [requireVerified, setRequireVerified] = createSignal(true);
23
+
24
+ const handleSubmit = async (e: SubmitEvent) => {
25
+ e.preventDefault();
26
+ const trimmed = domain().trim().toLowerCase();
27
+ if (!isDomain(trimmed)) return;
28
+ const result = await claim({
29
+ domain: trimmed,
30
+ auto_join_on_signup: autoJoin(),
31
+ require_email_verified: requireVerified(),
32
+ });
33
+ if (result) {
34
+ setDomain("");
35
+ props.onSuccess?.(result);
36
+ } else if (error()) {
37
+ props.onError?.(new Error(error() as string));
38
+ }
39
+ };
40
+
41
+ return (
42
+ <form class="space-y-4" on:submit={handleSubmit}>
43
+ <Show when={error()}>
44
+ <div
45
+ class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
46
+ role="alert"
47
+ aria-live="polite"
48
+ >
49
+ {error()}
50
+ </div>
51
+ </Show>
52
+
53
+ <div class="space-y-2">
54
+ <label class="text-sm font-medium leading-none" for="yauth-domain-input">
55
+ Domain
56
+ </label>
57
+ <input
58
+ class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
59
+ id="yauth-domain-input"
60
+ name="domain"
61
+ type="text"
62
+ placeholder="acme.com"
63
+ value={domain()}
64
+ on:input={(e) => setDomain(e.currentTarget.value)}
65
+ required
66
+ autocomplete="off"
67
+ disabled={submitting()}
68
+ />
69
+ </div>
70
+
71
+ <label class="flex items-center gap-2 text-sm">
72
+ <input
73
+ type="checkbox"
74
+ checked={autoJoin()}
75
+ on:change={(e) => setAutoJoin(e.currentTarget.checked)}
76
+ disabled={submitting()}
77
+ />
78
+ Auto-join matching users on signup
79
+ </label>
80
+
81
+ <label class="flex items-center gap-2 text-sm">
82
+ <input
83
+ type="checkbox"
84
+ checked={requireVerified()}
85
+ on:change={(e) => setRequireVerified(e.currentTarget.checked)}
86
+ disabled={submitting()}
87
+ />
88
+ Require verified email before auto-join
89
+ </label>
90
+
91
+ <button
92
+ class="inline-flex h-9 w-full cursor-pointer items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
93
+ type="submit"
94
+ disabled={submitting() || !isDomain(domain().trim())}
95
+ >
96
+ {submitting() ? "Claiming…" : "Claim domain"}
97
+ </button>
98
+
99
+ <Show when={lastCreated()}>
100
+ {(result) => (
101
+ <div class="rounded-md border border-input bg-muted/40 p-3 space-y-2">
102
+ <p class="text-xs text-muted-foreground">
103
+ Publish the following DNS TXT record to verify ownership. The token is shown ONCE —
104
+ copy it now.
105
+ </p>
106
+ <div>
107
+ <span class="text-xs font-medium">Record</span>
108
+ <code class="mt-1 block break-all font-mono text-xs">{result().dns_record_name}</code>
109
+ </div>
110
+ <div>
111
+ <span class="text-xs font-medium">Value</span>
112
+ <code class="mt-1 block break-all font-mono text-xs">
113
+ {result().verification_token}
114
+ </code>
115
+ </div>
116
+ </div>
117
+ )}
118
+ </Show>
119
+ </form>
120
+ );
121
+ };
@@ -0,0 +1,94 @@
1
+ import { type Component, For, Show } from "solid-js";
2
+ import { createDomains } from "../hooks/create-organizations";
3
+
4
+ export interface DomainListProps {
5
+ organizationId: string;
6
+ }
7
+
8
+ const STATUS_LABEL: Record<string, string> = {
9
+ pending: "Pending verification",
10
+ verified: "Verified",
11
+ failed: "Verification failed",
12
+ };
13
+
14
+ /**
15
+ * Read-only-with-actions listing of an org's claimed domains. Admins get
16
+ * verify / remove buttons; non-admins still see the list (the backend gate
17
+ * is on member-of-org rather than admin-of-org).
18
+ */
19
+ export const DomainList: Component<DomainListProps> = (props) => {
20
+ const { domains, loading, error, verify, remove, submitting } = createDomains(
21
+ () => props.organizationId,
22
+ );
23
+
24
+ return (
25
+ <div class="space-y-3">
26
+ <Show when={error()}>
27
+ <div
28
+ class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
29
+ role="alert"
30
+ aria-live="polite"
31
+ >
32
+ {error()}
33
+ </div>
34
+ </Show>
35
+
36
+ <Show when={loading()}>
37
+ <p class="text-sm text-muted-foreground">Loading domains…</p>
38
+ </Show>
39
+
40
+ <Show when={!loading() && domains().length === 0}>
41
+ <p class="text-sm text-muted-foreground">No domains claimed yet.</p>
42
+ </Show>
43
+
44
+ <ul class="divide-y divide-border">
45
+ <For each={domains()}>
46
+ {(d) => (
47
+ <li class="flex items-center justify-between gap-3 py-2">
48
+ <div class="min-w-0 flex-1">
49
+ <div class="flex items-center gap-2">
50
+ <code class="font-mono text-sm">{d.domain}</code>
51
+ <span
52
+ class={`rounded-full px-2 py-0.5 text-xs ${
53
+ d.status === "verified"
54
+ ? "bg-emerald-500/10 text-emerald-700"
55
+ : d.status === "failed"
56
+ ? "bg-destructive/10 text-destructive"
57
+ : "bg-muted text-muted-foreground"
58
+ }`}
59
+ >
60
+ {STATUS_LABEL[d.status] ?? d.status}
61
+ </span>
62
+ </div>
63
+ <p class="text-xs text-muted-foreground">
64
+ Auto-join {d.auto_join_on_signup ? "on" : "off"} · default role{" "}
65
+ {d.default_role_on_auto_join}
66
+ </p>
67
+ </div>
68
+ <div class="flex gap-2">
69
+ <Show when={d.status !== "verified"}>
70
+ <button
71
+ type="button"
72
+ class="inline-flex h-8 cursor-pointer items-center justify-center rounded-md border border-input bg-transparent px-3 text-xs font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
73
+ on:click={() => verify(d.id)}
74
+ disabled={submitting()}
75
+ >
76
+ Verify
77
+ </button>
78
+ </Show>
79
+ <button
80
+ type="button"
81
+ class="inline-flex h-8 cursor-pointer items-center justify-center rounded-md border border-input bg-transparent px-3 text-xs font-medium text-destructive shadow-sm transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"
82
+ on:click={() => remove(d.id)}
83
+ disabled={submitting()}
84
+ >
85
+ Remove
86
+ </button>
87
+ </div>
88
+ </li>
89
+ )}
90
+ </For>
91
+ </ul>
92
+ </div>
93
+ );
94
+ };
@@ -0,0 +1,81 @@
1
+ import type { CreateDomainResponse, VerifyDomainResponse } from "@yackey-labs/yauth-client";
2
+ import { type Component, createSignal, Show } from "solid-js";
3
+ import { createDomains } from "../hooks/create-organizations";
4
+
5
+ export interface DomainVerifyStepProps {
6
+ organizationId: string;
7
+ /** The domain claim to verify — usually the result of `<DomainClaim />`. */
8
+ claim: CreateDomainResponse;
9
+ onVerified?: (result: VerifyDomainResponse) => void;
10
+ }
11
+
12
+ /**
13
+ * Final step of the domain-onboarding flow. Displays the DNS TXT record the
14
+ * operator must publish, plus a "Run verification" button that hits the
15
+ * verify endpoint. The verification is best-effort — operators can retry.
16
+ */
17
+ export const DomainVerifyStep: Component<DomainVerifyStepProps> = (props) => {
18
+ const { verify, error, submitting } = createDomains(() => props.organizationId);
19
+ const [lastResult, setLastResult] = createSignal<VerifyDomainResponse | null>(null);
20
+
21
+ const handleVerify = async () => {
22
+ const result = await verify(props.claim.domain.id);
23
+ setLastResult(result);
24
+ if (result?.verified) {
25
+ props.onVerified?.(result);
26
+ }
27
+ };
28
+
29
+ return (
30
+ <div class="space-y-3">
31
+ <div class="rounded-md border border-input bg-muted/40 p-3 space-y-2">
32
+ <p class="text-xs text-muted-foreground">
33
+ Publish the following DNS TXT record at your DNS provider, then click "Run verification".
34
+ Some DNS providers can take up to 60 seconds to propagate.
35
+ </p>
36
+ <div>
37
+ <span class="text-xs font-medium">Record</span>
38
+ <code class="mt-1 block break-all font-mono text-xs">{props.claim.dns_record_name}</code>
39
+ </div>
40
+ <div>
41
+ <span class="text-xs font-medium">Value</span>
42
+ <code class="mt-1 block break-all font-mono text-xs">
43
+ {props.claim.verification_token}
44
+ </code>
45
+ </div>
46
+ </div>
47
+
48
+ <Show when={error()}>
49
+ <div
50
+ class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
51
+ role="alert"
52
+ aria-live="polite"
53
+ >
54
+ {error()}
55
+ </div>
56
+ </Show>
57
+
58
+ <Show when={lastResult() && !lastResult()?.verified}>
59
+ <div class="rounded-md bg-amber-500/10 px-3 py-2 text-sm text-amber-700">
60
+ Verification did not find the expected DNS TXT record. Double-check the record name +
61
+ value, then retry.
62
+ </div>
63
+ </Show>
64
+
65
+ <Show when={lastResult()?.verified}>
66
+ <div class="rounded-md bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700">
67
+ Domain verified. JIT auto-join is now active for matching users.
68
+ </div>
69
+ </Show>
70
+
71
+ <button
72
+ type="button"
73
+ class="inline-flex h-9 w-full cursor-pointer items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-50"
74
+ on:click={handleVerify}
75
+ disabled={submitting()}
76
+ >
77
+ {submitting() ? "Verifying…" : "Run verification"}
78
+ </button>
79
+ </div>
80
+ );
81
+ };
@@ -0,0 +1,82 @@
1
+ import type { MembershipResponse } from "@yackey-labs/yauth-client";
2
+ import { type Component, createSignal } from "solid-js";
3
+ import { Show } from "solid-js/web";
4
+ import { createInvitation } from "../hooks/create-organizations";
5
+
6
+ export interface InvitationAcceptProps {
7
+ /** Pre-fill the token (e.g. from a query string). */
8
+ token?: string;
9
+ onSuccess?: (membership: MembershipResponse) => void;
10
+ onError?: (error: Error) => void;
11
+ }
12
+
13
+ export const InvitationAccept: Component<InvitationAcceptProps> = (props) => {
14
+ const { accept, submitting, error } = createInvitation();
15
+ const [token, setToken] = createSignal(props.token ?? "");
16
+ const [accepted, setAccepted] = createSignal<MembershipResponse | null>(null);
17
+
18
+ const handleSubmit = async (e: SubmitEvent) => {
19
+ e.preventDefault();
20
+ const result = await accept(token().trim());
21
+ if (result) {
22
+ setAccepted(result);
23
+ props.onSuccess?.(result);
24
+ } else if (error()) {
25
+ props.onError?.(new Error(error() as string));
26
+ }
27
+ };
28
+
29
+ return (
30
+ <form class="space-y-4" on:submit={handleSubmit}>
31
+ <Show when={error()}>
32
+ <div
33
+ class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
34
+ role="alert"
35
+ aria-live="polite"
36
+ >
37
+ {error()}
38
+ </div>
39
+ </Show>
40
+
41
+ <Show when={accepted()}>
42
+ <div
43
+ class="rounded-md bg-primary/10 px-3 py-2 text-sm"
44
+ role="status"
45
+ aria-live="polite"
46
+ >
47
+ Invitation accepted. Welcome to the organization!
48
+ </div>
49
+ </Show>
50
+
51
+ <Show when={!accepted()}>
52
+ <div class="space-y-2">
53
+ <label
54
+ class="text-sm font-medium leading-none"
55
+ for="yauth-invitation-accept-token"
56
+ >
57
+ Invitation token
58
+ </label>
59
+ <input
60
+ class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 font-mono text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
61
+ id="yauth-invitation-accept-token"
62
+ name="token"
63
+ type="text"
64
+ value={token()}
65
+ on:input={(e) => setToken(e.currentTarget.value)}
66
+ required
67
+ autocomplete="off"
68
+ disabled={submitting()}
69
+ />
70
+ </div>
71
+
72
+ <button
73
+ class="inline-flex h-9 w-full cursor-pointer items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
74
+ type="submit"
75
+ disabled={submitting() || !token().trim()}
76
+ >
77
+ {submitting() ? "Accepting…" : "Accept invitation"}
78
+ </button>
79
+ </Show>
80
+ </form>
81
+ );
82
+ };
@@ -0,0 +1,108 @@
1
+ import type { CreateInvitationResponse } from "@yackey-labs/yauth-client";
2
+ import { type Component, createSignal } from "solid-js";
3
+ import { Show } from "solid-js/web";
4
+ import { createMembers } from "../hooks/create-organizations";
5
+
6
+ export interface InviteFormProps {
7
+ organizationId: string;
8
+ onSuccess?: (result: CreateInvitationResponse) => void;
9
+ onError?: (error: Error) => void;
10
+ }
11
+
12
+ const isEmail = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
13
+
14
+ export const InviteForm: Component<InviteFormProps> = (props) => {
15
+ const { invite, error } = createMembers(() => props.organizationId);
16
+
17
+ const [email, setEmail] = createSignal("");
18
+ const [role, setRole] = createSignal("member");
19
+ const [submitting, setSubmitting] = createSignal(false);
20
+ const [lastToken, setLastToken] = createSignal<string | null>(null);
21
+
22
+ const handleSubmit = async (e: SubmitEvent) => {
23
+ e.preventDefault();
24
+ const trimmed = email().trim();
25
+ if (!isEmail(trimmed)) return;
26
+ setSubmitting(true);
27
+ const result = await invite({ email: trimmed, role: role() });
28
+ setSubmitting(false);
29
+ if (result) {
30
+ setLastToken(result.token);
31
+ setEmail("");
32
+ props.onSuccess?.(result);
33
+ } else if (error()) {
34
+ props.onError?.(new Error(error() as string));
35
+ }
36
+ };
37
+
38
+ return (
39
+ <form class="space-y-4" on:submit={handleSubmit}>
40
+ <Show when={error()}>
41
+ <div
42
+ class="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
43
+ role="alert"
44
+ aria-live="polite"
45
+ >
46
+ {error()}
47
+ </div>
48
+ </Show>
49
+
50
+ <div class="space-y-2">
51
+ <label
52
+ class="text-sm font-medium leading-none"
53
+ for="yauth-invite-email"
54
+ >
55
+ Email
56
+ </label>
57
+ <input
58
+ class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
59
+ id="yauth-invite-email"
60
+ name="email"
61
+ type="email"
62
+ value={email()}
63
+ on:input={(e) => setEmail(e.currentTarget.value)}
64
+ required
65
+ autocomplete="email"
66
+ disabled={submitting()}
67
+ />
68
+ </div>
69
+
70
+ <div class="space-y-2">
71
+ <label class="text-sm font-medium leading-none" for="yauth-invite-role">
72
+ Role
73
+ </label>
74
+ <select
75
+ class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
76
+ id="yauth-invite-role"
77
+ name="role"
78
+ value={role()}
79
+ on:change={(e) => setRole(e.currentTarget.value)}
80
+ disabled={submitting()}
81
+ >
82
+ <option value="member">Member</option>
83
+ <option value="admin">Admin</option>
84
+ </select>
85
+ </div>
86
+
87
+ <button
88
+ class="inline-flex h-9 w-full cursor-pointer items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
89
+ type="submit"
90
+ disabled={submitting() || !isEmail(email().trim())}
91
+ >
92
+ {submitting() ? "Sending…" : "Send invitation"}
93
+ </button>
94
+
95
+ <Show when={lastToken()}>
96
+ <div class="rounded-md border border-input bg-muted/40 p-3">
97
+ <p class="text-xs text-muted-foreground">
98
+ Invitation created. Deliver this token to the invitee — it will
99
+ not be shown again.
100
+ </p>
101
+ <code class="mt-2 block break-all font-mono text-xs">
102
+ {lastToken()}
103
+ </code>
104
+ </div>
105
+ </Show>
106
+ </form>
107
+ );
108
+ };