Openstatus www.openstatus.dev
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore: improve invitation flow (#828)

* chore: improve invitation flow

* chore: remove comment

* chore: add june invitation tracker

* fix: types

authored by

Maximilian Kaske and committed by
GitHub
4d865673 fb9d3f47

+185 -40
+27
apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/team/_components/info-banner.tsx
··· 1 + "use client"; 2 + 3 + import Link from "next/link"; 4 + import { useParams } from "next/navigation"; 5 + import { Info } from "lucide-react"; 6 + 7 + import { Alert, AlertDescription, AlertTitle } from "@openstatus/ui"; 8 + 9 + export function InfoBanner() { 10 + const params = useParams<{ workspaceSlug: string }>(); 11 + return ( 12 + <Alert className="bg-muted/50"> 13 + <Info className="h-4 w-4" /> 14 + <AlertTitle>You&apos;re workspace name is empty</AlertTitle> 15 + <AlertDescription> 16 + To inform your team about the workspace name, please set it in the{" "} 17 + <Link 18 + href={`/app/${params.workspaceSlug}/settings/general`} 19 + className="text-foreground inline-flex items-center font-medium underline underline-offset-4 hover:no-underline" 20 + > 21 + general 22 + </Link>{" "} 23 + settings. 24 + </AlertDescription> 25 + </Alert> 26 + ); 27 + }
+2
apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/team/page.tsx
··· 4 4 import { columns as userColumns } from "@/components/data-table/user/columns"; 5 5 import { DataTable as UserDataTable } from "@/components/data-table/user/data-table"; 6 6 import { api } from "@/trpc/server"; 7 + import { InfoBanner } from "./_components/info-banner"; 7 8 import { InviteButton } from "./_components/invite-button"; 8 9 9 10 export default async function TeamPage() { ··· 16 17 return ( 17 18 <div className="flex flex-col gap-4"> 18 19 {isFreePlan ? <ProFeatureAlert feature="Team members" /> : null} 20 + {!isFreePlan && !workspace.name ? <InfoBanner /> : null} 19 21 {/* TODO: only show if isAdmin */} 20 22 <div className="flex justify-end"> 21 23 <InviteButton disabled={isFreePlan} />
+35 -10
apps/web/src/app/app/invite/page.tsx
··· 1 - import Link from "next/link"; 2 1 import { z } from "zod"; 3 2 4 - import { Button } from "@openstatus/ui"; 3 + import { Alert, AlertDescription, AlertTitle, Separator } from "@openstatus/ui"; 5 4 5 + import { Icons } from "@/components/icons"; 6 6 import { api } from "@/trpc/server"; 7 + import { LinkCards } from "./_components/link-cards"; 8 + 9 + const AlertTriangle = Icons["alert-triangle"]; 7 10 8 11 /** 9 12 * allowed URL search params ··· 18 21 searchParams: { [key: string]: string | string[] | undefined }; 19 22 }) { 20 23 const search = searchParamsSchema.safeParse(searchParams); 24 + const { message, data } = search.success 25 + ? await api.invitation.acceptInvitation.mutate({ token: search.data.token }) 26 + : { message: "Unavailable invitation token.", data: undefined }; 27 + 28 + const workspace = await api.workspace.getWorkspace.query(); 21 29 22 - const message = search.success 23 - ? await api.invitation.acceptInvitation.mutate({ token: search.data.token }) 24 - : "Invalid token"; 30 + if (!data) { 31 + return ( 32 + <div className="mx-auto flex h-full max-w-xl flex-1 flex-col items-center justify-center gap-4"> 33 + <h1 className="text-2xl font-semibold">Invitation</h1> 34 + <Alert variant="destructive"> 35 + <AlertTriangle className="h-4 w-4" /> 36 + <AlertTitle>Something went wrong</AlertTitle> 37 + <AlertDescription>{message}</AlertDescription> 38 + </Alert> 39 + <Separator className="my-4" /> 40 + <p className="text-muted-foreground">Quick Links</p> 41 + <LinkCards slug={workspace.slug} /> 42 + </div> 43 + ); 44 + } 25 45 26 46 return ( 27 - <div className="flex h-full flex-1 flex-col items-center justify-center gap-4"> 28 - <p className="text-lg text-muted-foreground">{message}</p> 29 - <Button> 30 - <Link href="/app">Dashboard</Link> 31 - </Button> 47 + <div className="mx-auto flex h-full max-w-xl flex-1 flex-col items-center justify-center gap-4"> 48 + <h1 className="text-2xl font-semibold">Invitation</h1> 49 + <Alert> 50 + <Icons.check className="h-4 w-4" /> 51 + <AlertTitle>Ready to go</AlertTitle> 52 + <AlertDescription>{message}</AlertDescription> 53 + </Alert> 54 + <Separator className="my-4" /> 55 + <p className="text-muted-foreground">Quick Links</p> 56 + <LinkCards slug={data.slug} /> 32 57 </div> 33 58 ); 34 59 }
+2 -1
packages/analytics/src/type.ts
··· 20 20 | { event: "User Signed In" } 21 21 | { event: "User Vercel Beta" } 22 22 | { event: "Notification Created"; provider: string } 23 - | { event: "Subscribe to Status Page"; slug: string }; 23 + | { event: "Subscribe to Status Page"; slug: string } 24 + | { event: "Invitation Created"; emailTo: string; workspaceId: number };
+13 -1
packages/api/src/analytics.ts
··· 46 46 47 47 export async function trackNewStatusReport() {} 48 48 49 - export async function trackNewInvitation() {} 49 + export async function trackNewInvitation( 50 + user: User, 51 + config: { emailTo: string; workspaceId: number }, 52 + ) { 53 + await analytics.identify(user.id, { 54 + userId: user.id, 55 + email: user.email, 56 + }); 57 + await trackAnalytics({ 58 + event: "Invitation Created", 59 + ...config, 60 + }); 61 + }
+45 -28
packages/api/src/router/invitation.ts
··· 5 5 import { 6 6 insertInvitationSchema, 7 7 invitation, 8 + selectWorkspaceSchema, 8 9 user, 9 10 usersToWorkspaces, 10 11 } from "@openstatus/db/src/schema"; ··· 33 34 where: and( 34 35 eq(invitation.workspaceId, opts.ctx.workspace.id), 35 36 gte(invitation.expiresAt, new Date()), 36 - isNull(invitation.acceptedAt), 37 + isNull(invitation.acceptedAt) 37 38 ), 38 39 }) 39 40 ).length; ··· 51 52 52 53 const token = crypto.randomUUID(); 53 54 54 - await fetch("https://api.resend.com/emails", { 55 - method: "POST", 56 - headers: { 57 - "Content-Type": "application/json", 58 - Authorization: `Bearer ${process.env.RESEND_API_KEY}`, 59 - }, 60 - body: JSON.stringify({ 61 - to: email, 62 - from: "Maximilian Kaske <max@openstatus.dev>", 63 - subject: "You have been invited to join OpenStatus.dev", 64 - html: `<p>Click here to join the workspace: <a href='https://openstatus.dev/app/invite?token=${token}'>accept invitation</a></p>`, 65 - }), 66 - }); 67 - 68 55 const _invitation = await opts.ctx.db 69 56 .insert(invitation) 70 57 .values({ email, expiresAt, token, workspaceId: opts.ctx.workspace.id }) ··· 73 60 74 61 if (process.env.NODE_ENV === "development") { 75 62 console.log( 76 - `>>>> Invitation token: http://localhost:3000/app/invite?token=${token} <<<< `, 63 + `>>>> Invitation token: http://localhost:3000/app/invite?token=${token} <<<< ` 77 64 ); 65 + } else { 66 + await fetch("https://api.resend.com/emails", { 67 + method: "POST", 68 + headers: { 69 + "Content-Type": "application/json", 70 + Authorization: `Bearer ${process.env.RESEND_API_KEY}`, 71 + }, 72 + body: JSON.stringify({ 73 + to: email, 74 + from: "OpenStatus <ping@openstatus.dev>", 75 + subject: `You have been invited to join OpenStatus.dev`, 76 + html: `<p>You have been invited by ${opts.ctx.user.email} ${!!opts.ctx.workspace.name ? `to join the workspace '${opts.ctx.workspace.name}'.` : "to join a workspace."}</p> 77 + <br> 78 + <p>Click here to access the workspace: <a href='https://openstatus.dev/app/invite?token=${_invitation.token}'>accept invitation</a>.</p> 79 + <p>If you don't have an account yet, it will require you to create one.</p> 80 + `, 81 + }), 82 + }); 78 83 } 79 - // TODO: 80 - await trackNewInvitation(); 84 + 85 + await trackNewInvitation(opts.ctx.user, { 86 + emailTo: email, 87 + workspaceId: opts.ctx.workspace.id, 88 + }); 81 89 82 90 return _invitation; 83 91 }), ··· 90 98 .where( 91 99 and( 92 100 eq(invitation.id, opts.input.id), 93 - eq(invitation.workspaceId, opts.ctx.workspace.id), 94 - ), 101 + eq(invitation.workspaceId, opts.ctx.workspace.id) 102 + ) 95 103 ) 96 104 .run(); 97 105 }), ··· 101 109 where: and( 102 110 eq(invitation.workspaceId, opts.ctx.workspace.id), 103 111 gte(invitation.expiresAt, new Date()), 104 - isNull(invitation.acceptedAt), 112 + isNull(invitation.acceptedAt) 105 113 ), 106 114 }); 107 115 return _invitations; ··· 126 134 */ 127 135 acceptInvitation: publicProcedure 128 136 .input(z.object({ token: z.string() })) 137 + .output( 138 + z.object({ 139 + message: z.string(), 140 + data: selectWorkspaceSchema.optional(), 141 + }) 142 + ) 129 143 .mutation(async (opts) => { 130 144 const _invitation = await opts.ctx.db.query.invitation.findFirst({ 131 145 where: and( 132 146 eq(invitation.token, opts.input.token), 133 - isNull(invitation.acceptedAt), 147 + isNull(invitation.acceptedAt) 134 148 ), 135 149 with: { 136 150 workspace: true, 137 151 }, 138 152 }); 139 153 140 - if (!opts.ctx.session?.user?.id) return "Missing user"; 154 + if (!opts.ctx.session?.user?.id) return { message: "Missing user." }; 141 155 142 156 const _user = await opts.ctx.db.query.user.findFirst({ 143 157 where: eq(user.id, Number(opts.ctx.session.user.id)), 144 158 }); 145 159 146 - if (!_user) return "Invalid user"; 160 + if (!_user) return { message: "Invalid user." }; 147 161 148 - if (!_invitation) return "Invalid invitation token"; 162 + if (!_invitation) return { message: "Invalid invitation token." }; 149 163 150 164 if (_invitation.email !== _user.email) 151 - return "You are not invited to this workspace"; 165 + return { message: "You are not invited to this workspace." }; 152 166 153 167 if (_invitation.expiresAt.getTime() < new Date().getTime()) { 154 - return "Invitation expired"; 168 + return { message: "Invitation expired." }; 155 169 } 156 170 157 171 await opts.ctx.db ··· 169 183 }) 170 184 .run(); 171 185 172 - return "Invitation accepted"; 186 + return { 187 + message: "Invitation accepted.", 188 + data: _invitation.workspace, 189 + }; 173 190 }), 174 191 });