Skip to content

Commit 8901f5f

Browse files
committed
added send verification email
1 parent 88738cd commit 8901f5f

3 files changed

Lines changed: 133 additions & 8 deletions

File tree

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx

Lines changed: 130 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

3-
import { SmartFormDialog } from "@/components/form-dialog";
3+
import { FormDialog, SmartFormDialog } from "@/components/form-dialog";
4+
import { InputField, SelectField } from "@/components/form-fields";
45
import { SettingCard } from "@/components/settings";
56
import { DeleteUserDialog, ImpersonateUserDialog } from "@/components/user-dialogs";
67
import { useThemeWatcher } from '@/lib/theme';
@@ -10,9 +11,9 @@ import { useAsyncCallback } from "@stackframe/stack-shared/dist/hooks/use-async-
1011
import { fromNow } from "@stackframe/stack-shared/dist/utils/dates";
1112
import { throwErr } from '@stackframe/stack-shared/dist/utils/errors';
1213
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
13-
import { ActionCell, Avatar, AvatarFallback, AvatarImage, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Input, Separator, SimpleTooltip, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography, cn } from "@stackframe/stack-ui";
14+
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, ActionCell, Avatar, AvatarFallback, AvatarImage, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Input, Separator, SimpleTooltip, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography, cn } from "@stackframe/stack-ui";
1415
import { AtSign, Calendar, Check, Hash, Mail, MoreHorizontal, Shield, SquareAsterisk, X } from "lucide-react";
15-
import { useMemo, useRef, useState } from "react";
16+
import { useEffect, useMemo, useRef, useState } from "react";
1617
import * as yup from "yup";
1718
import { PageLayout } from "../../page-layout";
1819
import { useAdminApp } from "../../use-admin-app";
@@ -167,6 +168,7 @@ type MetadataEditorProps = {
167168
function MetadataEditor({ title, initialValue, onUpdate, hint }: MetadataEditorProps) {
168169
const formatJson = (json: string) => JSON.stringify(JSON.parse(json), null, 2);
169170
const [hasChanged, setHasChanged] = useState(false);
171+
const [isMounted, setIsMounted] = useState(false);
170172

171173
const { mounted, theme } = useThemeWatcher();
172174

@@ -180,6 +182,14 @@ function MetadataEditor({ title, initialValue, onUpdate, hint }: MetadataEditorP
180182
}
181183
}, [value]);
182184

185+
// Ensure proper mounting lifecycle
186+
useEffect(() => {
187+
setIsMounted(true);
188+
return () => {
189+
setIsMounted(false);
190+
};
191+
}, []);
192+
183193
const handleSave = async () => {
184194
if (isJson) {
185195
const formatted = formatJson(value);
@@ -189,14 +199,18 @@ function MetadataEditor({ title, initialValue, onUpdate, hint }: MetadataEditorP
189199
}
190200
};
191201

202+
// Only render Monaco when both mounted states are true
203+
const shouldRenderMonaco = mounted && isMounted;
204+
192205
return <div className="flex flex-col">
193206
<h3 className='text-sm mb-4 font-semibold'>
194207
{title}
195208
<SimpleTooltip tooltip={hint} type="info" inline className="ml-2 mb-[2px]" />
196209
</h3>
197-
{mounted && (
210+
{shouldRenderMonaco ? (
198211
<div className={cn("rounded-md overflow-hidden", theme !== 'dark' && "border")}>
199212
<MonacoEditor
213+
key={`monaco-${theme}`} // Force recreation on theme change
200214
height="240px"
201215
defaultLanguage="json"
202216
value={value}
@@ -217,6 +231,10 @@ function MetadataEditor({ title, initialValue, onUpdate, hint }: MetadataEditorP
217231
}}
218232
/>
219233
</div>
234+
) : (
235+
<div className={cn("rounded-md overflow-hidden h-[240px] flex items-center justify-center", theme !== 'dark' && "border")}>
236+
<div className="text-sm text-muted-foreground">Loading editor...</div>
237+
</div>
220238
)}
221239
<div className={cn('self-end flex items-end gap-2 transition-all h-0 opacity-0 overflow-hidden', hasChanged && 'h-[48px] opacity-100')}>
222240
<Button
@@ -404,9 +422,101 @@ function AddEmailDialog({ user, open, onOpenChange }: AddEmailDialogProps) {
404422
);
405423
}
406424

425+
type SendVerificationEmailDialogProps = {
426+
channel: ServerContactChannel,
427+
open: boolean,
428+
onOpenChange: (open: boolean) => void,
429+
};
430+
431+
function SendVerificationEmailDialog({ channel, open, onOpenChange }: SendVerificationEmailDialogProps) {
432+
const stackAdminApp = useAdminApp();
433+
const project = stackAdminApp.useProject();
434+
const domains = project.config.domains;
435+
436+
return (
437+
<FormDialog
438+
title="Send Verification Email"
439+
description={`Send a verification email to ${channel.value}? The email will contain a callback link to your domain.`}
440+
open={open}
441+
onOpenChange={onOpenChange}
442+
formSchema={yup.object({
443+
selected: yup.string().defined(),
444+
localhostPort: yup.number().test("required-if-localhost", "Required if localhost is selected", (value, context) => {
445+
return context.parent.selected === "localhost" ? value !== undefined : true;
446+
}),
447+
handlerPath: yup.string().optional(),
448+
})}
449+
okButton={{
450+
label: "Send",
451+
}}
452+
render={({ control, watch }) => (
453+
<>
454+
<SelectField
455+
control={control}
456+
name="selected"
457+
label="Domain"
458+
options={[
459+
...domains.map((domain, index) => ({ value: index.toString(), label: domain.domain })),
460+
...(project.config.allowLocalhost ? [{ value: "localhost", label: "localhost" }] : [])
461+
]}
462+
/>
463+
{watch("selected") === "localhost" && (
464+
<>
465+
<InputField
466+
control={control}
467+
name="localhostPort"
468+
label="Localhost Port"
469+
placeholder="3000"
470+
type="number"
471+
/>
472+
<Accordion type="single" collapsible className="w-full">
473+
<AccordionItem value="item-1">
474+
<AccordionTrigger>Advanced</AccordionTrigger>
475+
<AccordionContent className="flex flex-col gap-8">
476+
<div className="flex flex-col gap-2">
477+
<InputField
478+
label="Handler path"
479+
name="handlerPath"
480+
control={control}
481+
placeholder='/handler'
482+
/>
483+
<Typography variant="secondary" type="footnote">
484+
only modify this if you changed the default handler path in your app
485+
</Typography>
486+
</div>
487+
</AccordionContent>
488+
</AccordionItem>
489+
</Accordion>
490+
</>
491+
)}
492+
</>
493+
)}
494+
onSubmit={async (values) => {
495+
let baseUrl: string;
496+
let handlerPath: string;
497+
if (values.selected === "localhost") {
498+
baseUrl = `http://localhost:${values.localhostPort}`;
499+
handlerPath = values.handlerPath || '/handler';
500+
} else {
501+
const domain = domains[parseInt(values.selected)];
502+
baseUrl = domain.domain;
503+
handlerPath = domain.handlerPath;
504+
}
505+
const callbackUrl = new URL(handlerPath + '/email-verification', baseUrl).toString();
506+
console.log(callbackUrl);
507+
await channel.sendVerificationEmail({ callbackUrl });
508+
}}
509+
/>
510+
);
511+
}
512+
407513
function ContactChannelsSection({ user }: ContactChannelsSectionProps) {
408514
const contactChannels = user.useContactChannels();
409515
const [isAddEmailDialogOpen, setIsAddEmailDialogOpen] = useState(false);
516+
const [sendVerificationEmailDialog, setSendVerificationEmailDialog] = useState<{
517+
channel: ServerContactChannel,
518+
isOpen: boolean,
519+
} | null>(null);
410520

411521
const toggleUsedForAuth = async (channel: ServerContactChannel) => {
412522
await channel.update({ usedForAuth: !channel.usedForAuth });
@@ -441,6 +551,18 @@ function ContactChannelsSection({ user }: ContactChannelsSectionProps) {
441551
onOpenChange={setIsAddEmailDialogOpen}
442552
/>
443553

554+
{sendVerificationEmailDialog && (
555+
<SendVerificationEmailDialog
556+
channel={sendVerificationEmailDialog.channel}
557+
open={sendVerificationEmailDialog.isOpen}
558+
onOpenChange={(open) => {
559+
if (!open) {
560+
setSendVerificationEmailDialog(null);
561+
}
562+
}}
563+
/>
564+
)}
565+
444566
{contactChannels.length === 0 ? (
445567
<div className="flex flex-col items-center gap-2 p-4 border rounded-md bg-muted/10">
446568
<p className='text-sm text-gray-500 text-center'>
@@ -488,7 +610,10 @@ function ContactChannelsSection({ user }: ContactChannelsSectionProps) {
488610
...(!channel.isVerified ? [{
489611
item: "Send verification email",
490612
onClick: async () => {
491-
await channel.sendVerificationEmail();
613+
setSendVerificationEmailDialog({
614+
channel,
615+
isOpen: true,
616+
});
492617
},
493618
}] : []),
494619
{

apps/dashboard/src/components/dev-error-notifier.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const neverNotify = [
1212
let callbacks: ((prop: string, args: any[]) => void)[] = [];
1313

1414
if (process.env.NODE_ENV === 'development' && isBrowserLike()) {
15-
for (const prop of ["warn", "error"] as const) {
15+
for (const prop of ["error"] as const) {
1616
const original = console[prop];
1717
console[prop] = (...args) => {
1818
original(...args);
@@ -31,7 +31,7 @@ export function DevErrorNotifier() {
3131
setTimeout(() => {
3232
toast.toast({
3333
title: `[DEV] console.${prop} called!`,
34-
description: `Please check the browser console. ${args.join(" ")}`,
34+
description: `Please check the browser console. ${args.join(" ").slice(0, 100)}...`,
3535
variant: "destructive",
3636
});
3737
});

packages/template/src/lib/stack-app/contact-channels/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export type ContactChannel = {
99
isVerified: boolean,
1010
usedForAuth: boolean,
1111

12-
sendVerificationEmail(): Promise<void>,
12+
sendVerificationEmail(options?: { callbackUrl?: string }): Promise<void>,
1313
update(data: ContactChannelUpdateOptions): Promise<void>,
1414
delete(): Promise<void>,
1515
}

0 commit comments

Comments
 (0)