Skip to content

Commit f0bdc3b

Browse files
authored
Proper invitations (#66)
* Proper invitations: work-in-progress * Progress * Remove comment * Remove comment * Add comment * Remove comment * Remove comment * Error handling * Clean up * Formatting * Progress * Read & edit invite fields on user settings page * styles * remove note * Progress * Expired UI * Progress * Progress * Progress * Progress * Progress * Progress * Progress * Progress * Progress * Fix form request error logic * Fix joinable-as-is schema issue * Progress * Remove alert * Resolve TODOs * Fix accessDetails storage bug * More lack-of-auth fixes
1 parent af821ed commit f0bdc3b

33 files changed

+1151
-313
lines changed

app/source/api/JSONAPI.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type {
1515
DocWithErrors,
1616
DocWithMeta,
1717
RelationshipsWithData,
18+
ResourceIdentifierObject,
1819
} from "jsonapi-typescript";
1920

2021
export interface ToOneRelationship<

app/source/api/requestApi.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,6 @@ export default async function requestApi<TSuccessfulResponsePayload>(
7171
responseBody: JSONAPI.DocWithErrors | undefined,
7272
) => MaybePromise<number>;
7373
headers?: Record<string, string>;
74-
isNotJSONAPI?: boolean;
7574
urlSearchParams?: URLSearchParams;
7675
urlPrefix?: string;
7776
},
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
3+
* If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
*
5+
* Project: Back Of Your Hand (https://backofyourhand.com)
6+
* Repository: https://github.com/adam-lynch/back-of-your-hand
7+
* Copyright © 2025 Adam Lynch (https://adamlynch.com)
8+
*/
9+
10+
import type { UserOrganization } from "./resourceObjects";
11+
12+
export type AcceptedUserOrganization = UserOrganization & {
13+
attributes: UserOrganization["attributes"] & {
14+
inviteStatus: "accepted";
15+
};
16+
relationships: UserOrganization["relationships"] & {
17+
user: NonNullable<NonNullable<UserOrganization["relationships"]>["user"]>;
18+
};
19+
};

app/source/api/resourceObjects.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,17 @@ export type User = JSONAPI.ResourceObject<
9595
export type UserOrganization = JSONAPI.ResourceObject<
9696
"userOrganization",
9797
TimestampedResourceAttributes & {
98+
inviteIssuedAt: string | null;
99+
inviteStatus: "accepted" | "invited" | "uninvited";
100+
inviteTokenMaxAge: number;
101+
inviteUserEmail: string;
102+
inviteUserFirstName: string;
103+
inviteUserLastName: string;
98104
jobTitle: OptionalAttributeValue<string>;
99105
role: "admin" | "standard";
100106
},
101107
{
102108
organization: JSONAPI.ToOneRelationship;
103-
user: JSONAPI.ToOneRelationship;
109+
user: JSONAPI.ToOneRelationship<"optional">;
104110
}
105111
>;

app/source/library/App.svelte

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import AutoSavingFieldsPlayground from "./playground/AutoSavingFieldsPlayground.svelte";
3232
import RouteGuard from "./routing/RouteGuard.svelte";
3333
import { ClientRequestError } from "../api/requestApi";
34+
import CheckboxPlayground from "./playground/CheckboxPlayground.svelte";
3435
3536
export let unhandledError: Error | null = null;
3637
export let url = "";
@@ -136,6 +137,10 @@
136137
path="/playground/button"
137138
component={ButtonPlayground}
138139
/>
140+
<Route
141+
path="/playground/checkbox"
142+
component={CheckboxPlayground}
143+
/>
139144
<Route
140145
path="/playground/form"
141146
component={FormPlayground}

app/source/library/DeleteUserConfirmationModal.svelte renamed to app/source/library/DeleteUserOrganizationConfirmationModal.svelte

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,35 +11,40 @@
1111
import { derived } from "svelte/store";
1212
import toast from "svelte-french-toast";
1313
import type { User, UserOrganization } from "../api/resourceObjects";
14-
import prettifyUserName from "../utilities/prettifyUserName";
14+
import prettifyUserOrganizationName from "../utilities/prettifyUserOrganizationName";
1515
import ConfirmationModal from "./ConfirmationModal.svelte";
1616
import { user as currentUser } from "../userData/store";
1717
import api from "../api";
1818
import eventEmitter from "../utilities/eventEmitter";
1919
import getCommonToastOptions from "./utilities/getCommonToastOptions";
2020
21-
export let onConfirm: (
22-
user: User,
23-
userOrganization: UserOrganization,
24-
) => void = () => {};
25-
export let user: User;
21+
export let onConfirm: (data: {
22+
isCurrentUser: boolean;
23+
userOrganizationId: string;
24+
}) => void = () => {};
25+
export let user: User | null;
2626
export let userOrganization: UserOrganization;
2727
28-
let isCurrentUser = derived(
29-
currentUser,
30-
($currentUser) => $currentUser?.id === user.id,
28+
let isCurrentUser = derived(currentUser, ($currentUser) =>
29+
$currentUser && user ? $currentUser.id === user.id : false,
3130
);
3231
33-
const title = `Delete ${$isCurrentUser ? "your account" : prettifyUserName(user)}?`;
32+
const title = `Delete ${$isCurrentUser ? "your account" : prettifyUserOrganizationName(userOrganization, user)}?`;
3433
3534
const handleConfirm = async () => {
3635
const userOrganizationId = userOrganization.id;
37-
onConfirm(user, userOrganization);
36+
onConfirm({
37+
isCurrentUser: $isCurrentUser,
38+
userOrganizationId: userOrganization.id,
39+
});
3840
await api.deleteResource<UserOrganization>(
3941
"userOrganization",
4042
userOrganizationId,
4143
);
42-
eventEmitter.emit("user-deleted", user.id);
44+
eventEmitter.emit("user-organization-deleted", {
45+
isCurrentUser: $isCurrentUser,
46+
userOrganizationId,
47+
});
4348
toast.success("User deleted!", getCommonToastOptions());
4449
};
4550
</script>

app/source/library/InviteModal.svelte

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
-->
99

1010
<script lang="ts">
11+
import { createEventDispatcher } from "svelte";
1112
import { writable } from "svelte/store";
1213
import toast from "svelte-french-toast";
1314
import MultiFieldFormModal from "./MultiFieldFormModal.svelte";
@@ -21,40 +22,80 @@
2122
import ErrorMessages from "./forms/ErrorMessages.svelte";
2223
import getCommonToastOptions from "./utilities/getCommonToastOptions";
2324
import requestApi from "../api/requestApi";
25+
import { organization } from "../userData/store";
26+
import type {
27+
OmitTimestampedResourceAttributes,
28+
UserOrganization,
29+
} from "../api/resourceObjects";
2430
2531
let email = "";
32+
let firstName = "";
2633
let jobTitle = "";
27-
let name = "";
34+
let lastName = "";
2835
function onFormReset() {
2936
email = "";
37+
firstName = "";
3038
jobTitle = "";
31-
name = "";
39+
lastName = "";
3240
}
3341
3442
const selectedRole = writable<"standard" | "admin">("standard");
3543
44+
const dispatch = createEventDispatcher();
45+
3646
const handleOnSubmit = async () => {
37-
await requestApi("userorganizations/request_invite", {
38-
body: {
39-
email,
47+
if (!$organization) {
48+
throw new Error("No organization");
49+
}
50+
51+
const userOrganization: Omit<
52+
OmitTimestampedResourceAttributes<UserOrganization>,
53+
"id"
54+
> = {
55+
attributes: {
56+
inviteUserEmail: email,
57+
inviteUserFirstName: firstName,
58+
inviteUserLastName: lastName,
4059
jobTitle,
41-
name,
4260
role: $selectedRole,
4361
},
44-
isNotJSONAPI: true,
62+
relationships: {
63+
organization: {
64+
data: {
65+
id: $organization.id,
66+
type: "organization",
67+
},
68+
},
69+
user: {
70+
data: null,
71+
},
72+
},
73+
type: "userOrganization",
74+
};
75+
76+
await requestApi("userorganizations/actions/invite", {
77+
body: {
78+
data: userOrganization,
79+
},
4580
method: "POST",
4681
});
4782
83+
dispatch("invited");
84+
4885
toast.success("User invited!", getCommonToastOptions());
4986
};
5087
</script>
5188

5289
<MultiFieldFormModal
53-
description="NOTE: invite emails can take up to a day to arrive."
90+
decideIfGeneralErrorsAreUnexpected={(errorMessages) =>
91+
!errorMessages.every((errorMessage) =>
92+
errorMessage.includes("already exists"),
93+
)}
5494
schema={yup.object({
5595
email: commonSchema.email().label("Email"),
56-
jobTitle: yup.string().label("jobTitle"),
57-
name: yup.string().label("Full name").required(),
96+
firstName: yup.string().label("First name").required(),
97+
jobTitle: yup.string().label("Job title"),
98+
lastName: yup.string().label("Last name").required(),
5899
role: yup.string().label("Role").required(),
59100
})}
60101
on:formReset={onFormReset}
@@ -91,8 +132,8 @@
91132
aria-describedby={ariaDescribedby}
92133
bind:value={email}
93134
class={_class}
94-
name={_name}
95135
{id}
136+
name={_name}
96137
required
97138
{theme}
98139
type="email"
@@ -101,8 +142,8 @@
101142

102143
<Field
103144
{form}
104-
labelText="Full name"
105-
name="name"
145+
labelText="First name"
146+
name="firstName"
106147
let:_class
107148
let:_name
108149
let:ariaDescribedby
@@ -112,10 +153,33 @@
112153
>
113154
<TextInput
114155
aria-describedby={ariaDescribedby}
115-
bind:value={name}
156+
bind:value={firstName}
116157
class={_class}
158+
{id}
117159
name={_name}
160+
{theme}
161+
required
162+
type="text"
163+
/>
164+
</Field>
165+
166+
<Field
167+
{form}
168+
labelText="Last name"
169+
name="lastName"
170+
let:_class
171+
let:_name
172+
let:ariaDescribedby
173+
let:id
174+
let:theme
175+
theme="dark"
176+
>
177+
<TextInput
178+
aria-describedby={ariaDescribedby}
179+
bind:value={lastName}
180+
class={_class}
118181
{id}
182+
name={_name}
119183
{theme}
120184
required
121185
type="text"
@@ -137,8 +201,8 @@
137201
aria-describedby={ariaDescribedby}
138202
bind:value={$selectedRole}
139203
class={_class}
140-
name={_name}
141204
{id}
205+
name={_name}
142206
options={["admin", "standard"].map((role) => ({
143207
label: prettifyRole(role),
144208
value: role,
@@ -163,8 +227,8 @@
163227
aria-describedby={ariaDescribedby}
164228
bind:value={jobTitle}
165229
class={_class}
166-
name={_name}
167230
{id}
231+
name={_name}
168232
{theme}
169233
type="text"
170234
/>

app/source/library/MultiFieldFormModal.svelte

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
import type { createForm } from "felte";
1717
import { writable } from "svelte/store";
1818
19+
export let decideIfGeneralErrorsAreUnexpected:
20+
| ((errorMessages: string[]) => boolean)
21+
| undefined = undefined;
1922
export let description = "";
2023
export let onSubmit: (
2124
form: ReturnType<typeof createForm>,
@@ -65,6 +68,7 @@
6568
<slot name="content-top" />
6669
<MultiFieldForm
6770
action="#"
71+
{decideIfGeneralErrorsAreUnexpected}
6872
isVisible={isOpen}
6973
let:form
7074
let:isSubmitting

app/source/library/OrganizationPlanModal.svelte

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@
5959
"userAgentData" in navigator ? navigator.userAgentData : null,
6060
userCount,
6161
},
62-
isNotJSONAPI: true,
6362
method: "POST",
6463
urlPrefix: "open",
6564
});

app/source/library/PageWithSubNavigation.svelte

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@
4848
<div
4949
class="page-with-sub-navigation__title-wrapper page__title-wrapper"
5050
>
51-
<h1>{$title}</h1>
51+
<h1
52+
{...{
53+
/* eslint-disable-next-line svelte/no-at-html-tags */
54+
}}>{@html title}</h1
55+
>
5256
</div>
5357
</slot>
5458

0 commit comments

Comments
 (0)