rogasper commited on
Commit
f68b7db
·
1 Parent(s): c665186

feat: implement PasswordInput component for enhanced password handling in sign-in and sign-up forms. Update forms to utilize the new component, improving user experience with password visibility toggle and validation feedback.

Browse files
apps/web/src/components/sign-in-form.tsx CHANGED
@@ -9,6 +9,7 @@ import z from "zod";
9
 
10
  import { authClient } from "@/lib/auth-client";
11
  import { trackUmamiEvent, AnalyticsEvent } from "@/lib/umami";
 
12
 
13
  import Loader from "./loader";
14
 
@@ -110,13 +111,13 @@ export default function SignInForm({ onSwitchToSignUp }: { onSwitchToSignUp: ()
110
  Lupa password?
111
  </Link>
112
  </div>
113
- <Input
114
  id={field.name}
115
  name={field.name}
116
- type="password"
117
  value={field.state.value}
118
  onBlur={field.handleBlur}
119
- onChange={(e) => field.handleChange(e.target.value)}
 
120
  />
121
  {field.state.meta.errors.map((error) => (
122
  <p key={error?.message} className="text-red-500 text-sm">
 
9
 
10
  import { authClient } from "@/lib/auth-client";
11
  import { trackUmamiEvent, AnalyticsEvent } from "@/lib/umami";
12
+ import { PasswordInput } from "@/components/ui/PasswordInput";
13
 
14
  import Loader from "./loader";
15
 
 
111
  Lupa password?
112
  </Link>
113
  </div>
114
+ <PasswordInput
115
  id={field.name}
116
  name={field.name}
 
117
  value={field.state.value}
118
  onBlur={field.handleBlur}
119
+ onChange={field.handleChange}
120
+ autoComplete="current-password"
121
  />
122
  {field.state.meta.errors.map((error) => (
123
  <p key={error?.message} className="text-red-500 text-sm">
apps/web/src/components/sign-up-form.tsx CHANGED
@@ -11,9 +11,29 @@ import z from "zod";
11
  import { authClient } from "@/lib/auth-client";
12
  import { trpc } from "@/utils/trpc";
13
  import { trackUmamiEvent, AnalyticsEvent } from "@/lib/umami";
 
14
 
15
  import Loader from "./loader";
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: () => void }) {
18
  const navigate = useNavigate({
19
  from: "/",
@@ -25,6 +45,7 @@ export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: ()
25
  defaultValues: {
26
  email: "",
27
  password: "",
 
28
  name: "",
29
  },
30
  onSubmit: async ({ value }) => {
@@ -53,11 +74,7 @@ export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: ()
53
  );
54
  },
55
  validators: {
56
- onSubmit: z.object({
57
- name: z.string().min(2, "Name must be at least 2 characters"),
58
- email: z.email("Invalid email address"),
59
- password: z.string().regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/, "Password must be at least 8 characters with uppercase, lowercase, and number"),
60
- }),
61
  },
62
  });
63
 
@@ -130,13 +147,38 @@ export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: ()
130
  {(field) => (
131
  <div className="space-y-2">
132
  <Label htmlFor={field.name}>Password</Label>
133
- <Input
134
  id={field.name}
135
  name={field.name}
136
- type="password"
137
  value={field.state.value}
138
  onBlur={field.handleBlur}
139
- onChange={(e) => field.handleChange(e.target.value)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  />
141
  {field.state.meta.errors.map((error) => (
142
  <p key={error?.message} className="text-red-500 text-sm">
 
11
  import { authClient } from "@/lib/auth-client";
12
  import { trpc } from "@/utils/trpc";
13
  import { trackUmamiEvent, AnalyticsEvent } from "@/lib/umami";
14
+ import { PasswordInput } from "@/components/ui/PasswordInput";
15
 
16
  import Loader from "./loader";
17
 
18
+ const passwordSchema = z
19
+ .string()
20
+ .regex(
21
+ /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/,
22
+ "Password must be at least 8 characters with uppercase, lowercase, and number",
23
+ );
24
+
25
+ const signUpSchema = z
26
+ .object({
27
+ name: z.string().min(2, "Name must be at least 2 characters"),
28
+ email: z.email("Invalid email address"),
29
+ password: passwordSchema,
30
+ confirmPassword: z.string().min(1, "Confirm your password"),
31
+ })
32
+ .refine((data) => data.password === data.confirmPassword, {
33
+ message: "Password tidak cocok",
34
+ path: ["confirmPassword"],
35
+ });
36
+
37
  export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: () => void }) {
38
  const navigate = useNavigate({
39
  from: "/",
 
45
  defaultValues: {
46
  email: "",
47
  password: "",
48
+ confirmPassword: "",
49
  name: "",
50
  },
51
  onSubmit: async ({ value }) => {
 
74
  );
75
  },
76
  validators: {
77
+ onSubmit: signUpSchema,
 
 
 
 
78
  },
79
  });
80
 
 
147
  {(field) => (
148
  <div className="space-y-2">
149
  <Label htmlFor={field.name}>Password</Label>
150
+ <PasswordInput
151
  id={field.name}
152
  name={field.name}
 
153
  value={field.state.value}
154
  onBlur={field.handleBlur}
155
+ onChange={field.handleChange}
156
+ autoComplete="new-password"
157
+ placeholder="Minimal 8 karakter"
158
+ />
159
+ {field.state.meta.errors.map((error) => (
160
+ <p key={error?.message} className="text-red-500 text-sm">
161
+ {error?.message}
162
+ </p>
163
+ ))}
164
+ </div>
165
+ )}
166
+ </form.Field>
167
+ </div>
168
+
169
+ <div>
170
+ <form.Field name="confirmPassword">
171
+ {(field) => (
172
+ <div className="space-y-2">
173
+ <Label htmlFor={field.name}>Konfirmasi Password</Label>
174
+ <PasswordInput
175
+ id={field.name}
176
+ name={field.name}
177
+ value={field.state.value}
178
+ onBlur={field.handleBlur}
179
+ onChange={field.handleChange}
180
+ autoComplete="new-password"
181
+ placeholder="Ulangi password"
182
  />
183
  {field.state.meta.errors.map((error) => (
184
  <p key={error?.message} className="text-red-500 text-sm">
apps/web/src/components/ui/PasswordInput.tsx ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { Input } from "@labas/ui/components/input";
3
+ import { Button } from "@labas/ui/components/button";
4
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
5
+
6
+ type PasswordInputProps = {
7
+ id: string;
8
+ name?: string;
9
+ value: string;
10
+ onChange: (value: string) => void;
11
+ onBlur?: () => void;
12
+ placeholder?: string;
13
+ autoComplete?: string;
14
+ };
15
+
16
+ export function PasswordInput({
17
+ id,
18
+ name,
19
+ value,
20
+ onChange,
21
+ onBlur,
22
+ placeholder,
23
+ autoComplete,
24
+ }: PasswordInputProps) {
25
+ const [visible, setVisible] = useState(false);
26
+
27
+ return (
28
+ <div className="relative">
29
+ <Input
30
+ id={id}
31
+ name={name}
32
+ type={visible ? "text" : "password"}
33
+ value={value}
34
+ onBlur={onBlur}
35
+ onChange={(e) => onChange(e.target.value)}
36
+ placeholder={placeholder}
37
+ autoComplete={autoComplete}
38
+ className="pr-11"
39
+ />
40
+ <Button
41
+ type="button"
42
+ variant="ghost"
43
+ size="icon"
44
+ className="absolute right-0 top-0 h-full w-11 rounded-[var(--radius-lg)] text-muted-foreground hover:text-foreground"
45
+ aria-label={visible ? "Sembunyikan password" : "Tampilkan password"}
46
+ onClick={() => setVisible((prev) => !prev)}
47
+ >
48
+ <MaterialIcon name={visible ? "visibility_off" : "visibility"} className="text-xl" />
49
+ </Button>
50
+ </div>
51
+ );
52
+ }
packages/api/src/__tests__/credit.test.ts CHANGED
@@ -20,6 +20,8 @@ const MOCK_ENV = {
20
  PLATFORM_AI_BASE_URL: "https://api.openai.com/v1",
21
  PLATFORM_AI_MODEL: "gpt-4o-mini",
22
  DEFAULT_SIGNUP_CREDIT_TOKENS: "50000",
 
 
23
  };
24
 
25
  mock.module("@labas/env/server", () => ({
@@ -163,4 +165,18 @@ describe("Credit Utility", () => {
163
  expect(row.tokenBalance).toBe(0);
164
  });
165
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  });
 
20
  PLATFORM_AI_BASE_URL: "https://api.openai.com/v1",
21
  PLATFORM_AI_MODEL: "gpt-4o-mini",
22
  DEFAULT_SIGNUP_CREDIT_TOKENS: "50000",
23
+ FREE_CREDITS_ENABLED: true,
24
+ FREE_CREDITS_MAX_POOL: 1000000,
25
  };
26
 
27
  mock.module("@labas/env/server", () => ({
 
165
  expect(row.tokenBalance).toBe(0);
166
  });
167
  });
168
+
169
+ describe("autoRefillIfEligible", () => {
170
+ it("grants initial free credits to new user with zero balance", async () => {
171
+ const uid = makeUserId("refill");
172
+ await db.insert(schema.user).values({ id: uid, name: uid, email: `${uid}@test.com` });
173
+
174
+ const result = await credit.autoRefillIfEligible(uid);
175
+ expect(result.refilled).toBe(true);
176
+ expect(result.newBalance).toBe(50000);
177
+
178
+ const info = await credit.getUserCredit(uid);
179
+ expect(info.tokenBalance).toBe(50000);
180
+ });
181
+ });
182
  });
packages/api/src/routers/admin.ts CHANGED
@@ -5,7 +5,7 @@ import * as schema from "@labas/db";
5
  import { db } from "@labas/db";
6
  import { paginationSchema, paginateDefaults } from "../lib/pagination";
7
  import { throwNotFound, throwForbidden, throwBadRequest } from "../lib/errors";
8
- import { getUserCredit, getLastRefillAt, getPoolUsage, getConfig, setConfig } from "../lib/credit";
9
  import { env } from "@labas/env/server";
10
 
11
  function audit(adminUserId: string, action: string, targetUserId: string | null, details?: Record<string, unknown>) {
@@ -24,7 +24,16 @@ export const adminRouter = router({
24
  }),
25
 
26
  getMyCredit: protectedProcedure.query(async ({ ctx }) => {
27
- const credit = await getUserCredit(ctx.session.user.id);
 
 
 
 
 
 
 
 
 
28
  const lastRefill = await getLastRefillAt(ctx.session.user.id);
29
  const cooldownMs = 7 * 24 * 60 * 60 * 1000;
30
  const lastRefillTime = lastRefill?.createdAt ? new Date(lastRefill.createdAt).getTime() : 0;
 
5
  import { db } from "@labas/db";
6
  import { paginationSchema, paginateDefaults } from "../lib/pagination";
7
  import { throwNotFound, throwForbidden, throwBadRequest } from "../lib/errors";
8
+ import { getUserCredit, getLastRefillAt, getPoolUsage, getConfig, setConfig, autoRefillIfEligible } from "../lib/credit";
9
  import { env } from "@labas/env/server";
10
 
11
  function audit(adminUserId: string, action: string, targetUserId: string | null, details?: Record<string, unknown>) {
 
24
  }),
25
 
26
  getMyCredit: protectedProcedure.query(async ({ ctx }) => {
27
+ let credit = await getUserCredit(ctx.session.user.id);
28
+
29
+ // Proactively grant first free-credit refill so new users see balance before clicking Generate.
30
+ if (credit.tokenBalance <= 0) {
31
+ const refill = await autoRefillIfEligible(ctx.session.user.id);
32
+ if (refill.refilled && refill.newBalance !== undefined) {
33
+ credit = { ...credit, tokenBalance: refill.newBalance };
34
+ }
35
+ }
36
+
37
  const lastRefill = await getLastRefillAt(ctx.session.user.id);
38
  const cooldownMs = 7 * 24 * 60 * 60 * 1000;
39
  const lastRefillTime = lastRefill?.createdAt ? new Date(lastRefill.createdAt).getTime() : 0;
packages/api/src/routers/verification.ts CHANGED
@@ -7,6 +7,7 @@ import { eq, and, gt, sql } from "drizzle-orm";
7
  import { z } from "zod";
8
 
9
  import { sendOtpEmail } from "../lib/email";
 
10
  import { publicProcedure, router } from "../index";
11
  import { checkRateLimit } from "../lib/rate-limit";
12
 
@@ -88,6 +89,16 @@ export const verificationRouter = router({
88
 
89
  await db.delete(verification).where(eq(verification.id, record.id));
90
 
 
 
 
 
 
 
 
 
 
 
91
  return { success: true };
92
  }),
93
 
 
7
  import { z } from "zod";
8
 
9
  import { sendOtpEmail } from "../lib/email";
10
+ import { autoRefillIfEligible } from "../lib/credit";
11
  import { publicProcedure, router } from "../index";
12
  import { checkRateLimit } from "../lib/rate-limit";
13
 
 
89
 
90
  await db.delete(verification).where(eq(verification.id, record.id));
91
 
92
+ const [verifiedUser] = await db
93
+ .select({ id: user.id })
94
+ .from(user)
95
+ .where(eq(user.email, input.email))
96
+ .limit(1);
97
+
98
+ if (verifiedUser) {
99
+ await autoRefillIfEligible(verifiedUser.id);
100
+ }
101
+
102
  return { success: true };
103
  }),
104