@m5kdev/web-ui 0.1.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/LICENSE +621 -0
- package/README.md +17 -0
- package/package.json +169 -0
- package/src/animations/card.motion.ts +9 -0
- package/src/components/AvatarUpload.tsx +133 -0
- package/src/components/Button.tsx +14 -0
- package/src/components/Calendar.css +684 -0
- package/src/components/Calendar.tsx +32 -0
- package/src/components/CardsSelect.tsx +155 -0
- package/src/components/CollapsibleSidebarMenuItem.tsx +57 -0
- package/src/components/ColorPicker.tsx +56 -0
- package/src/components/CopyButton.tsx +45 -0
- package/src/components/CropDialog.tsx +154 -0
- package/src/components/DialogProvider.tsx +105 -0
- package/src/components/ErrorFallback.tsx +17 -0
- package/src/components/FileDropzone.tsx +120 -0
- package/src/components/MultiSelectDropdown.tsx +233 -0
- package/src/components/Orb.tsx +288 -0
- package/src/components/PageAlert.tsx +121 -0
- package/src/components/SelectChips.tsx +40 -0
- package/src/components/SidebarItem.tsx +26 -0
- package/src/components/Steps.tsx +340 -0
- package/src/components/TablerIconPicker.tsx +4260 -0
- package/src/components/app-header.tsx +40 -0
- package/src/components/blur-card.tsx +132 -0
- package/src/components/features-section-demo-1.tsx +127 -0
- package/src/components/features-section-demo-2.tsx +102 -0
- package/src/components/features-section-demo-3.tsx +272 -0
- package/src/components/mode-toggle.tsx +31 -0
- package/src/components/nav-main.tsx +69 -0
- package/src/components/pricing-cards.tsx +133 -0
- package/src/components/shared/ButtonCopy.tsx +50 -0
- package/src/components/team-switcher.tsx +83 -0
- package/src/components/theme-provider.tsx +74 -0
- package/src/components/typewriter.tsx +90 -0
- package/src/components/ui/alert-dialog.tsx +133 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/avatar.tsx +47 -0
- package/src/components/ui/badge.tsx +33 -0
- package/src/components/ui/bento-grid.tsx +54 -0
- package/src/components/ui/bento-grid2.tsx +66 -0
- package/src/components/ui/breadcrumb.tsx +101 -0
- package/src/components/ui/button.tsx +50 -0
- package/src/components/ui/card.tsx +55 -0
- package/src/components/ui/checkbox.tsx +26 -0
- package/src/components/ui/collapsible.tsx +9 -0
- package/src/components/ui/dialog.tsx +119 -0
- package/src/components/ui/dropdown-menu.tsx +186 -0
- package/src/components/ui/floating-navbar.tsx +78 -0
- package/src/components/ui/form.tsx +167 -0
- package/src/components/ui/image.tsx +55 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/pagination.tsx +105 -0
- package/src/components/ui/progress.tsx +23 -0
- package/src/components/ui/resizable-navbar.tsx +260 -0
- package/src/components/ui/segment-control.tsx +143 -0
- package/src/components/ui/select.tsx +153 -0
- package/src/components/ui/separator.tsx +24 -0
- package/src/components/ui/sheet.tsx +121 -0
- package/src/components/ui/sidebar.tsx +736 -0
- package/src/components/ui/skeleton.tsx +7 -0
- package/src/components/ui/slider.tsx +23 -0
- package/src/components/ui/sonner.tsx +27 -0
- package/src/components/ui/spinner.tsx +45 -0
- package/src/components/ui/switch.tsx +27 -0
- package/src/components/ui/table.tsx +90 -0
- package/src/components/ui/tabs.tsx +52 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/timeline.tsx +95 -0
- package/src/components/ui/toast.tsx +126 -0
- package/src/components/ui/tooltip.tsx +55 -0
- package/src/components/ui/typewriter-effect.tsx +181 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/useDialog.ts +25 -0
- package/src/icons/GoogleIcon.tsx +32 -0
- package/src/icons/LinkedInIcon.tsx +30 -0
- package/src/icons/MicrosoftIcon.tsx +21 -0
- package/src/lib/chatwoot.ts +51 -0
- package/src/lib/utils.ts +6 -0
- package/src/modules/app/components/AppLoader.tsx +9 -0
- package/src/modules/app/components/AppShell.tsx +21 -0
- package/src/modules/app/components/AppSidebar.tsx +26 -0
- package/src/modules/app/components/AppSidebarContent.tsx +73 -0
- package/src/modules/app/components/AppSidebarHeader.tsx +57 -0
- package/src/modules/app/components/AppSidebarInvites.tsx +32 -0
- package/src/modules/app/components/AppSidebarUser.tsx +128 -0
- package/src/modules/auth/components/AdminUserManagement.tsx +1136 -0
- package/src/modules/auth/components/AdminWaitlist.tsx +358 -0
- package/src/modules/auth/components/AuthLayout.tsx +13 -0
- package/src/modules/auth/components/AuthProviders.tsx +105 -0
- package/src/modules/auth/components/AuthRouter.tsx +29 -0
- package/src/modules/auth/components/ClaimAccountRoute.tsx +242 -0
- package/src/modules/auth/components/ErrorAuthRoute.tsx +121 -0
- package/src/modules/auth/components/ForgotPasswordForm.tsx +58 -0
- package/src/modules/auth/components/ForgotPasswordRoute.tsx +27 -0
- package/src/modules/auth/components/InviteFriends.tsx +273 -0
- package/src/modules/auth/components/LastUsedBadge.tsx +22 -0
- package/src/modules/auth/components/LoginForm.tsx +104 -0
- package/src/modules/auth/components/LoginRoute.tsx +31 -0
- package/src/modules/auth/components/LogoutRoute.tsx +21 -0
- package/src/modules/auth/components/OrganizationAcceptInvitationRoute.tsx +161 -0
- package/src/modules/auth/components/OrganizationMembersRoute.tsx +730 -0
- package/src/modules/auth/components/OrganizationSettingsRoute.tsx +280 -0
- package/src/modules/auth/components/OrganizationSwitcher.tsx +148 -0
- package/src/modules/auth/components/ProfileRoute.tsx +104 -0
- package/src/modules/auth/components/RangeNuqsDatePicker.tsx +365 -0
- package/src/modules/auth/components/ResetPasswordForm.tsx +103 -0
- package/src/modules/auth/components/ResetPasswordRoute.tsx +27 -0
- package/src/modules/auth/components/SignupFormRoute.tsx +189 -0
- package/src/modules/auth/components/SignupRoute.tsx +53 -0
- package/src/modules/auth/components/UserPreferences.tsx +144 -0
- package/src/modules/auth/components/WaitlistCard.tsx +78 -0
- package/src/modules/auth/components/WaitlistCodeValidation.tsx +79 -0
- package/src/modules/billing/components/BillingBetaPage.tsx +124 -0
- package/src/modules/billing/components/BillingInvoicePage.tsx +180 -0
- package/src/modules/billing/components/BillingPlanSelect.tsx +14 -0
- package/src/modules/billing/components/BillingRouter.tsx +20 -0
- package/src/modules/billing/components/BillingSinglePlanSelect.tsx +172 -0
- package/src/modules/table/components/ColumnOrderAndVisibility.tsx +127 -0
- package/src/modules/table/components/NuqsTable.tsx +396 -0
- package/src/modules/table/components/TableFiltering.tsx +520 -0
- package/src/modules/table/components/TablePagination.tsx +59 -0
- package/src/modules/table/components/table.types.ts +11 -0
- package/src/modules/table/filterTransformers.ts +323 -0
- package/src/types.ts +4 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { Button, Card, CardBody, CardHeader, Chip, Input } from "@heroui/react";
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { CheckCircle2, Link2Icon, Mail, Send, Ticket, Users, Zap } from "lucide-react";
|
|
4
|
+
import { useMemo, useState } from "react";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { CopyButton } from "#components/CopyButton";
|
|
8
|
+
import type { UseBackendTRPC } from "#types";
|
|
9
|
+
|
|
10
|
+
export interface InviteFriendsProps {
|
|
11
|
+
useTRPC: UseBackendTRPC;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function InviteFriends({ useTRPC }: InviteFriendsProps) {
|
|
15
|
+
const trpc = useTRPC();
|
|
16
|
+
const queryClient = useQueryClient();
|
|
17
|
+
|
|
18
|
+
const { data: waitlist = [] } = useQuery(trpc.auth.listWaitlist.queryOptions());
|
|
19
|
+
const { data: count = 0 } = useQuery(trpc.auth.getUserWaitlistCount.queryOptions());
|
|
20
|
+
|
|
21
|
+
const invitesAvailable = Math.max(0, 3 - count);
|
|
22
|
+
|
|
23
|
+
const inviteMutation = useMutation(
|
|
24
|
+
trpc.auth.inviteToWaitlist.mutationOptions({
|
|
25
|
+
onSuccess: (result) => {
|
|
26
|
+
queryClient.setQueryData(
|
|
27
|
+
trpc.auth.getUserWaitlistCount.queryKey(),
|
|
28
|
+
(old) => (old ?? 0) + 1
|
|
29
|
+
);
|
|
30
|
+
queryClient.setQueryData(trpc.auth.listWaitlist.queryKey(), (old) => [
|
|
31
|
+
...(old ?? []),
|
|
32
|
+
result,
|
|
33
|
+
]);
|
|
34
|
+
toast.success("Invitation sent successfully!");
|
|
35
|
+
},
|
|
36
|
+
onError: (error) => {
|
|
37
|
+
toast.error(error.message);
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const createInvitationCodeMutation = useMutation(
|
|
43
|
+
trpc.auth.createInvitationCode.mutationOptions({
|
|
44
|
+
onSuccess: (result) => {
|
|
45
|
+
queryClient.setQueryData(
|
|
46
|
+
trpc.auth.getUserWaitlistCount.queryKey(),
|
|
47
|
+
(old) => (old ?? 0) + 1
|
|
48
|
+
);
|
|
49
|
+
queryClient.setQueryData(trpc.auth.listWaitlist.queryKey(), (old) => [
|
|
50
|
+
...(old ?? []),
|
|
51
|
+
result,
|
|
52
|
+
]);
|
|
53
|
+
toast.success("Code created successfully!");
|
|
54
|
+
},
|
|
55
|
+
onError: (error) => {
|
|
56
|
+
toast.error(error.message);
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const [email, setEmail] = useState("");
|
|
62
|
+
const [name, setName] = useState("");
|
|
63
|
+
const isLoading = inviteMutation.isPending || createInvitationCodeMutation.isPending;
|
|
64
|
+
|
|
65
|
+
const isEmailValid = useMemo(() => {
|
|
66
|
+
return z.email().safeParse(email).success;
|
|
67
|
+
}, [email]);
|
|
68
|
+
|
|
69
|
+
const handleInvite = async (e: React.FormEvent) => {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
if (!email) return;
|
|
72
|
+
|
|
73
|
+
inviteMutation.mutate({ email, name: name.length > 0 ? name : undefined });
|
|
74
|
+
setEmail("");
|
|
75
|
+
setName("");
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const handleCreateCode = async () => {
|
|
79
|
+
createInvitationCodeMutation.mutate({ name: name.length > 0 ? name : undefined });
|
|
80
|
+
setEmail("");
|
|
81
|
+
setName("");
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const getStatusColor = (status: string) => {
|
|
85
|
+
switch (status.toLowerCase()) {
|
|
86
|
+
case "accepted":
|
|
87
|
+
case "completed":
|
|
88
|
+
return "success";
|
|
89
|
+
case "invited":
|
|
90
|
+
return "warning";
|
|
91
|
+
case "waitlist":
|
|
92
|
+
return "default";
|
|
93
|
+
default:
|
|
94
|
+
return "default";
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className="max-w-3xl mx-auto p-6 space-y-8">
|
|
100
|
+
{/* Hero Section */}
|
|
101
|
+
<div className="text-center space-y-4">
|
|
102
|
+
<div className="inline-flex items-center justify-center p-4 bg-primary/10 rounded-full mb-2 ring-1 ring-primary/20">
|
|
103
|
+
<Ticket className="w-8 h-8 text-primary" />
|
|
104
|
+
</div>
|
|
105
|
+
<h1 className="text-3xl md:text-4xl font-bold tracking-tight">
|
|
106
|
+
Invite Friends & Skip the Waitlist
|
|
107
|
+
</h1>
|
|
108
|
+
<p className="text-lg text-default-600 max-w-lg mx-auto leading-relaxed">
|
|
109
|
+
Encourage your friends to join! Friends invited by you get{" "}
|
|
110
|
+
<span className="text-foreground font-medium">immediate access</span> and skip the
|
|
111
|
+
waitlist completely.
|
|
112
|
+
</p>
|
|
113
|
+
<div className="flex justify-center mt-4">
|
|
114
|
+
<Chip
|
|
115
|
+
color={invitesAvailable > 0 ? "warning" : "default"}
|
|
116
|
+
variant="flat"
|
|
117
|
+
size="lg"
|
|
118
|
+
startContent={
|
|
119
|
+
<Zap size={16} className={invitesAvailable > 0 ? "text-warning-600" : ""} />
|
|
120
|
+
}
|
|
121
|
+
className="font-medium"
|
|
122
|
+
>
|
|
123
|
+
{invitesAvailable} {invitesAvailable === 1 ? "Invite" : "Invites"} Remaining
|
|
124
|
+
</Chip>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div className="grid gap-6">
|
|
129
|
+
{/* Invite Form */}
|
|
130
|
+
<Card className="shadow-sm border border-default-200">
|
|
131
|
+
<CardHeader className="flex flex-col items-start gap-1 px-6 pt-6 pb-2">
|
|
132
|
+
<h3 className="text-xl font-semibold">Send an Invitation via Email</h3>
|
|
133
|
+
<p className="text-small text-default-600">
|
|
134
|
+
They'll receive a unique code to join instantly.
|
|
135
|
+
</p>
|
|
136
|
+
</CardHeader>
|
|
137
|
+
<CardBody className="px-6 pb-6">
|
|
138
|
+
<form onSubmit={handleInvite} className="flex flex-col gap-4">
|
|
139
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
140
|
+
<Input
|
|
141
|
+
label="Friend's Name (Optional)"
|
|
142
|
+
placeholder="e.g. Alex"
|
|
143
|
+
value={name}
|
|
144
|
+
onValueChange={setName}
|
|
145
|
+
variant="bordered"
|
|
146
|
+
labelPlacement="outside"
|
|
147
|
+
/>
|
|
148
|
+
<Input
|
|
149
|
+
type="email"
|
|
150
|
+
label="Email Address (Required for sending)"
|
|
151
|
+
placeholder="friend@example.com"
|
|
152
|
+
value={email}
|
|
153
|
+
onValueChange={setEmail}
|
|
154
|
+
variant="bordered"
|
|
155
|
+
labelPlacement="outside"
|
|
156
|
+
startContent={
|
|
157
|
+
<Mail
|
|
158
|
+
className="text-default-400 pointer-events-none flex-shrink-0"
|
|
159
|
+
size={16}
|
|
160
|
+
/>
|
|
161
|
+
}
|
|
162
|
+
/>
|
|
163
|
+
</div>
|
|
164
|
+
<div className="flex justify-end pt-2 gap-2">
|
|
165
|
+
<Button
|
|
166
|
+
color="primary"
|
|
167
|
+
variant="bordered"
|
|
168
|
+
type="button"
|
|
169
|
+
isLoading={isLoading}
|
|
170
|
+
isDisabled={invitesAvailable <= 0}
|
|
171
|
+
onPress={handleCreateCode}
|
|
172
|
+
className="font-medium"
|
|
173
|
+
endContent={!isLoading && <Link2Icon size={16} />}
|
|
174
|
+
>
|
|
175
|
+
Create Invitation Link
|
|
176
|
+
</Button>
|
|
177
|
+
<Button
|
|
178
|
+
color="primary"
|
|
179
|
+
type="submit"
|
|
180
|
+
isLoading={isLoading}
|
|
181
|
+
isDisabled={invitesAvailable <= 0 || !isEmailValid}
|
|
182
|
+
className="font-medium"
|
|
183
|
+
endContent={!isLoading && <Send size={16} />}
|
|
184
|
+
>
|
|
185
|
+
Send Invitation
|
|
186
|
+
</Button>
|
|
187
|
+
</div>
|
|
188
|
+
</form>
|
|
189
|
+
</CardBody>
|
|
190
|
+
</Card>
|
|
191
|
+
|
|
192
|
+
{/* Created Invitations Section */}
|
|
193
|
+
<div className="space-y-4">
|
|
194
|
+
<div className="flex items-center justify-between">
|
|
195
|
+
<h3 className="text-xl font-semibold">Created Invitations</h3>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
{waitlist.length > 0 ? (
|
|
199
|
+
<div className="flex flex-col gap-3">
|
|
200
|
+
{waitlist.map((item) => (
|
|
201
|
+
<div
|
|
202
|
+
key={item.id}
|
|
203
|
+
className="flex items-center justify-between p-4 border border-default-200 rounded-lg bg-content1"
|
|
204
|
+
>
|
|
205
|
+
<div className="flex flex-row gap-3">
|
|
206
|
+
<div>
|
|
207
|
+
<span className="text-sm font-medium">
|
|
208
|
+
{item.email || item.name || "Open Invitation Link"}
|
|
209
|
+
</span>
|
|
210
|
+
</div>
|
|
211
|
+
<Chip
|
|
212
|
+
size="sm"
|
|
213
|
+
color={getStatusColor(item.status)}
|
|
214
|
+
variant="flat"
|
|
215
|
+
className="capitalize"
|
|
216
|
+
>
|
|
217
|
+
{item.status.toLowerCase()}
|
|
218
|
+
</Chip>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<div className="flex items-center gap-3">
|
|
222
|
+
{item.code && (
|
|
223
|
+
<CopyButton
|
|
224
|
+
variant="flat"
|
|
225
|
+
color="success"
|
|
226
|
+
size="sm"
|
|
227
|
+
text={`${import.meta.env.VITE_APP_URL}/signup?code=${item.code}`}
|
|
228
|
+
isIconOnly
|
|
229
|
+
/>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
))}
|
|
234
|
+
</div>
|
|
235
|
+
) : (
|
|
236
|
+
<div className="text-center py-8 text-default-600 border border-dashed border-default-200 rounded-lg">
|
|
237
|
+
No invitations created yet.
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
{/* Benefits Grid */}
|
|
244
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 py-4">
|
|
245
|
+
<div className="flex flex-col items-center text-center p-6 rounded-2xl bg-content1 border border-default-100 shadow-sm">
|
|
246
|
+
<div className="p-3 bg-warning/10 text-warning rounded-full mb-4">
|
|
247
|
+
<Zap className="w-6 h-6" />
|
|
248
|
+
</div>
|
|
249
|
+
<h3 className="font-semibold mb-2">Instant Access</h3>
|
|
250
|
+
<p className="text-sm text-default-600">
|
|
251
|
+
Friends you invite skip the waitlist completely and get in right away.
|
|
252
|
+
</p>
|
|
253
|
+
</div>
|
|
254
|
+
<div className="flex flex-col items-center text-center p-6 rounded-2xl bg-content1 border border-default-100 shadow-sm">
|
|
255
|
+
<div className="p-3 bg-primary/10 text-primary rounded-full mb-4">
|
|
256
|
+
<Users className="w-6 h-6" />
|
|
257
|
+
</div>
|
|
258
|
+
<h3 className="font-semibold mb-2">Grow Your Network</h3>
|
|
259
|
+
<p className="text-sm text-default-600">Build your circle within the app from day one.</p>
|
|
260
|
+
</div>
|
|
261
|
+
<div className="flex flex-col items-center text-center p-6 rounded-2xl bg-content1 border border-default-100 shadow-sm">
|
|
262
|
+
<div className="p-3 bg-success/10 text-success rounded-full mb-4">
|
|
263
|
+
<CheckCircle2 className="w-6 h-6" />
|
|
264
|
+
</div>
|
|
265
|
+
<h3 className="font-semibold mb-2">Verified Status</h3>
|
|
266
|
+
<p className="text-sm text-default-600">
|
|
267
|
+
Invited members get a verified badge on their profile.
|
|
268
|
+
</p>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Badge, type BadgeProps } from "@heroui/react";
|
|
2
|
+
import { useTranslation } from "react-i18next";
|
|
3
|
+
|
|
4
|
+
export function LastUsedBadge({
|
|
5
|
+
lastMethod,
|
|
6
|
+
method,
|
|
7
|
+
children,
|
|
8
|
+
...props
|
|
9
|
+
}: BadgeProps & { lastMethod?: string | null; method?: string | null }) {
|
|
10
|
+
const { t } = useTranslation();
|
|
11
|
+
if (lastMethod !== method) return children;
|
|
12
|
+
return (
|
|
13
|
+
<Badge
|
|
14
|
+
{...props}
|
|
15
|
+
content={t("web-ui:auth.login.lastUsed")}
|
|
16
|
+
color="warning"
|
|
17
|
+
className="px-2 !right-[10%]"
|
|
18
|
+
>
|
|
19
|
+
{children}
|
|
20
|
+
</Badge>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Button, Input } from "@heroui/react";
|
|
2
|
+
import { authClient } from "@m5kdev/frontend/modules/auth/auth.lib";
|
|
3
|
+
import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
|
|
4
|
+
import { type SubmitHandler, useForm } from "react-hook-form";
|
|
5
|
+
import { useTranslation } from "react-i18next";
|
|
6
|
+
import { Link, useNavigate } from "react-router";
|
|
7
|
+
import { toast } from "sonner";
|
|
8
|
+
import { AuthProviders } from "./AuthProviders";
|
|
9
|
+
import { LastUsedBadge } from "./LastUsedBadge";
|
|
10
|
+
|
|
11
|
+
type Inputs = {
|
|
12
|
+
email: string;
|
|
13
|
+
password: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function LoginForm({ providers }: { providers?: string[] }) {
|
|
17
|
+
const lastMethod = authClient.getLastUsedLoginMethod();
|
|
18
|
+
const { register, handleSubmit } = useForm<Inputs>();
|
|
19
|
+
const { registerSession } = useSession();
|
|
20
|
+
const navigate = useNavigate();
|
|
21
|
+
const { t } = useTranslation();
|
|
22
|
+
|
|
23
|
+
const onSubmit: SubmitHandler<Inputs> = (data) => {
|
|
24
|
+
console.log(data);
|
|
25
|
+
authClient.signIn
|
|
26
|
+
.email({
|
|
27
|
+
email: data.email,
|
|
28
|
+
password: data.password,
|
|
29
|
+
})
|
|
30
|
+
.then((res) => {
|
|
31
|
+
console.log(res);
|
|
32
|
+
if (res.data?.user) {
|
|
33
|
+
registerSession(() => {
|
|
34
|
+
navigate("/");
|
|
35
|
+
});
|
|
36
|
+
} else if (res.error) {
|
|
37
|
+
toast.error(t("web-ui:auth.errors.authentication"), {
|
|
38
|
+
description: res.error.message,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
.catch((error) => {
|
|
43
|
+
toast.error(t("web-ui:auth.errors.server"), {
|
|
44
|
+
description: error.message,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<form onSubmit={handleSubmit(onSubmit)}>
|
|
51
|
+
<div className="grid gap-6">
|
|
52
|
+
<AuthProviders providers={providers} lastMethod={lastMethod} />
|
|
53
|
+
|
|
54
|
+
<div className="grid gap-6">
|
|
55
|
+
<div className="grid gap-2">
|
|
56
|
+
<LastUsedBadge lastMethod={lastMethod} method="email">
|
|
57
|
+
<label htmlFor="email" className="text-sm font-medium">
|
|
58
|
+
{t("web-ui:auth.login.email")}
|
|
59
|
+
</label>
|
|
60
|
+
</LastUsedBadge>
|
|
61
|
+
<Input
|
|
62
|
+
type="email"
|
|
63
|
+
placeholder={t("web-ui:auth.login.placeholder.email")}
|
|
64
|
+
variant="bordered"
|
|
65
|
+
isRequired
|
|
66
|
+
{...register("email", { required: true })}
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div className="grid gap-2">
|
|
71
|
+
<div className="flex items-center">
|
|
72
|
+
<label htmlFor="password" className="text-sm font-medium">
|
|
73
|
+
{t("web-ui:auth.login.password")}
|
|
74
|
+
</label>
|
|
75
|
+
<Link
|
|
76
|
+
to="/forgot-password"
|
|
77
|
+
className="ml-auto text-sm underline-offset-4 hover:underline"
|
|
78
|
+
>
|
|
79
|
+
{t("web-ui:auth.login.forgotPassword")}
|
|
80
|
+
</Link>
|
|
81
|
+
</div>
|
|
82
|
+
<Input
|
|
83
|
+
placeholder={t("web-ui:auth.login.password")}
|
|
84
|
+
type="password"
|
|
85
|
+
variant="bordered"
|
|
86
|
+
isRequired
|
|
87
|
+
{...register("password", { required: true })}
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
<Button type="submit" className="w-full" color="primary">
|
|
91
|
+
{t("web-ui:auth.login.button")}
|
|
92
|
+
</Button>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div className="text-center text-sm">
|
|
96
|
+
{t("web-ui:auth.login.noAccount")}{" "}
|
|
97
|
+
<Link to="/signup" className="underline underline-offset-4">
|
|
98
|
+
{t("web-ui:auth.login.signUp")}
|
|
99
|
+
</Link>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</form>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Card, CardBody, CardHeader } from "@heroui/react";
|
|
2
|
+
import { useTranslation } from "react-i18next";
|
|
3
|
+
import { Link } from "react-router";
|
|
4
|
+
import { LoginForm } from "./LoginForm";
|
|
5
|
+
|
|
6
|
+
export function LoginRoute({ providers }: { providers?: string[] }) {
|
|
7
|
+
const { t } = useTranslation();
|
|
8
|
+
return (
|
|
9
|
+
<div className="flex flex-col gap-6">
|
|
10
|
+
<Card>
|
|
11
|
+
<CardHeader className="text-center flex flex-col gap-1">
|
|
12
|
+
<p className="text-xl font-semibold">{t("web-ui:auth.login.welcome")}</p>
|
|
13
|
+
<p className="text-sm text-default-600">
|
|
14
|
+
{providers
|
|
15
|
+
? t("web-ui:auth.login.descriptionWithProviders")
|
|
16
|
+
: t("web-ui:auth.login.description")}
|
|
17
|
+
</p>
|
|
18
|
+
</CardHeader>
|
|
19
|
+
<CardBody>
|
|
20
|
+
<LoginForm providers={providers} />
|
|
21
|
+
</CardBody>
|
|
22
|
+
</Card>
|
|
23
|
+
<div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 [&_a]:hover:text-primary ">
|
|
24
|
+
{t("web-ui:common.byClickingContinue")}{" "}
|
|
25
|
+
<Link to="/terms-of-service">{t("web-ui:common.termsOfService")}</Link>{" "}
|
|
26
|
+
{t("web-ui:common.and")}{" "}
|
|
27
|
+
<Link to="/privacy-policy">{t("web-ui:common.privacyPolicy")}</Link>.
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Spinner } from "@heroui/react";
|
|
2
|
+
import { authClient } from "@m5kdev/frontend/modules/auth/auth.lib";
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { useNavigate } from "react-router";
|
|
5
|
+
|
|
6
|
+
export default function Logout() {
|
|
7
|
+
const navigate = useNavigate();
|
|
8
|
+
|
|
9
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies(authClient): authClient is a singleton
|
|
10
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies(navigate): navigate is a global hook
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
authClient.signOut();
|
|
13
|
+
navigate("/login");
|
|
14
|
+
}, []);
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex-1 justify-center align-center">
|
|
18
|
+
<Spinner />
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { Card, CardBody, CardHeader, Spinner } from "@heroui/react";
|
|
2
|
+
import { authClient } from "@m5kdev/frontend/modules/auth/auth.lib";
|
|
3
|
+
import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
|
|
4
|
+
import { useEffect, useMemo, useState } from "react";
|
|
5
|
+
import { useTranslation } from "react-i18next";
|
|
6
|
+
import { useLocation, useNavigate, useSearchParams } from "react-router";
|
|
7
|
+
import { toast } from "sonner";
|
|
8
|
+
|
|
9
|
+
type Phase = "idle" | "accepting" | "success" | "error";
|
|
10
|
+
|
|
11
|
+
export type OrganizationAcceptInvitationRouteProps = {
|
|
12
|
+
authReturnKey?: string;
|
|
13
|
+
loginPath?: string;
|
|
14
|
+
defaultRedirectPath?: string;
|
|
15
|
+
managerRedirectPath?: string;
|
|
16
|
+
managerRoles?: string[];
|
|
17
|
+
onInvalidateScopedQueries?: () => void | Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function OrganizationAcceptInvitationRoute({
|
|
21
|
+
authReturnKey = "org-auth-return",
|
|
22
|
+
loginPath = "/login",
|
|
23
|
+
defaultRedirectPath = "/",
|
|
24
|
+
managerRedirectPath = "/organization/members",
|
|
25
|
+
managerRoles = ["admin", "owner"],
|
|
26
|
+
onInvalidateScopedQueries,
|
|
27
|
+
}: OrganizationAcceptInvitationRouteProps) {
|
|
28
|
+
const { t } = useTranslation();
|
|
29
|
+
const [searchParams] = useSearchParams();
|
|
30
|
+
const { data: session, registerSession } = useSession();
|
|
31
|
+
const navigate = useNavigate();
|
|
32
|
+
const location = useLocation();
|
|
33
|
+
const invitationId = searchParams.get("id");
|
|
34
|
+
const [phase, setPhase] = useState<Phase>("idle");
|
|
35
|
+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
36
|
+
const managerRoleSet = useMemo(() => new Set(managerRoles), [managerRoles]);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!invitationId) {
|
|
40
|
+
setPhase("error");
|
|
41
|
+
setErrorMessage(t("web-ui:organization.invitation.errorMissing"));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!session) {
|
|
46
|
+
sessionStorage.setItem(authReturnKey, `${location.pathname}${location.search}`);
|
|
47
|
+
navigate(loginPath, { replace: true });
|
|
48
|
+
}
|
|
49
|
+
}, [
|
|
50
|
+
authReturnKey,
|
|
51
|
+
invitationId,
|
|
52
|
+
location.pathname,
|
|
53
|
+
location.search,
|
|
54
|
+
loginPath,
|
|
55
|
+
navigate,
|
|
56
|
+
session,
|
|
57
|
+
t,
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!session || !invitationId || phase !== "idle") {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let isMounted = true;
|
|
66
|
+
|
|
67
|
+
const run = async () => {
|
|
68
|
+
try {
|
|
69
|
+
setPhase("accepting");
|
|
70
|
+
const { data, error } = await authClient.organization.acceptInvitation({ invitationId });
|
|
71
|
+
if (error) {
|
|
72
|
+
throw new Error(error.message ?? t("web-ui:organization.invitation.acceptFailed"));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const organizationId =
|
|
76
|
+
(data as { invitation?: { organizationId?: string } | null } | null)?.invitation
|
|
77
|
+
?.organizationId ?? null;
|
|
78
|
+
const invitationRole =
|
|
79
|
+
(data as { invitation?: { role?: string } | null } | null)?.invitation?.role ?? "member";
|
|
80
|
+
|
|
81
|
+
if (organizationId) {
|
|
82
|
+
const result = await authClient.organization.setActive({ organizationId });
|
|
83
|
+
if (result.error) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
result.error.message ?? t("web-ui:organization.invitation.activateFailed")
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
registerSession(() => {
|
|
91
|
+
void onInvalidateScopedQueries?.();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (isMounted) {
|
|
95
|
+
setPhase("success");
|
|
96
|
+
toast.success(t("web-ui:organization.invitation.accepted"));
|
|
97
|
+
navigate(managerRoleSet.has(invitationRole) ? managerRedirectPath : defaultRedirectPath, {
|
|
98
|
+
replace: true,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (isMounted) {
|
|
103
|
+
setPhase("error");
|
|
104
|
+
setErrorMessage(
|
|
105
|
+
error instanceof Error
|
|
106
|
+
? error.message
|
|
107
|
+
: t("web-ui:organization.invitation.acceptFailed")
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
void run();
|
|
114
|
+
|
|
115
|
+
return () => {
|
|
116
|
+
isMounted = false;
|
|
117
|
+
};
|
|
118
|
+
}, [
|
|
119
|
+
defaultRedirectPath,
|
|
120
|
+
invitationId,
|
|
121
|
+
managerRedirectPath,
|
|
122
|
+
managerRoleSet,
|
|
123
|
+
navigate,
|
|
124
|
+
onInvalidateScopedQueries,
|
|
125
|
+
phase,
|
|
126
|
+
registerSession,
|
|
127
|
+
session,
|
|
128
|
+
t,
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
if (phase === "error") {
|
|
132
|
+
return (
|
|
133
|
+
<div className="min-h-screen flex items-center justify-center p-6">
|
|
134
|
+
<Card className="w-full max-w-lg">
|
|
135
|
+
<CardHeader className="text-lg font-semibold">
|
|
136
|
+
{t("web-ui:organization.invitation.error")}
|
|
137
|
+
</CardHeader>
|
|
138
|
+
<CardBody>{errorMessage ?? t("web-ui:organization.invitation.unableAccept")}</CardBody>
|
|
139
|
+
</Card>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div className="min-h-screen flex items-center justify-center p-6">
|
|
146
|
+
<Card className="w-full max-w-lg">
|
|
147
|
+
<CardHeader className="text-lg font-semibold">
|
|
148
|
+
{t("web-ui:organization.invitation.accepting")}
|
|
149
|
+
</CardHeader>
|
|
150
|
+
<CardBody className="flex items-center gap-3">
|
|
151
|
+
<Spinner size="sm" />
|
|
152
|
+
<span>
|
|
153
|
+
{phase === "success"
|
|
154
|
+
? t("web-ui:organization.invitation.redirecting")
|
|
155
|
+
: t("web-ui:organization.invitation.pleaseWait")}
|
|
156
|
+
</span>
|
|
157
|
+
</CardBody>
|
|
158
|
+
</Card>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|