human hand off bug
This commit is contained in:
parent
2855a20140
commit
962f991580
11 changed files with 226 additions and 34 deletions
|
|
@ -23,6 +23,8 @@ from app.schemas import (
|
|||
InvitePreview,
|
||||
LoginRequest,
|
||||
PasswordChangeRequest,
|
||||
PasswordResetRequest,
|
||||
PasswordResetResponse,
|
||||
)
|
||||
from app.services.member_invitations import get_valid_invitation
|
||||
from app.services.welcome_email import send_welcome_email
|
||||
|
|
@ -306,6 +308,35 @@ async def accept_invite(
|
|||
)
|
||||
|
||||
|
||||
@router.post("/reset-password", response_model=PasswordResetResponse)
|
||||
async def reset_password(
|
||||
payload: PasswordResetRequest,
|
||||
session: AsyncSession = Depends(get_postgres_session),
|
||||
) -> PasswordResetResponse:
|
||||
validate_password(payload.new_password)
|
||||
account = await session.scalar(
|
||||
select(Account).where(Account.email == str(payload.email).lower())
|
||||
)
|
||||
password_hash.verify(
|
||||
payload.new_password,
|
||||
account.password_hash if account is not None else dummy_password_hash,
|
||||
)
|
||||
if account is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No account was found for that email.",
|
||||
)
|
||||
if account.member_status == "invited":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Accept your invitation email to set your password before resetting it.",
|
||||
)
|
||||
account.password_hash = password_hash.hash(payload.new_password)
|
||||
account.must_change_password = False
|
||||
await session.commit()
|
||||
return PasswordResetResponse()
|
||||
|
||||
|
||||
@router.post("/change-password", response_model=AccountRead)
|
||||
async def change_password(
|
||||
payload: PasswordChangeRequest,
|
||||
|
|
|
|||
|
|
@ -104,6 +104,15 @@ class PasswordChangeRequest(BaseModel):
|
|||
new_password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
email: EmailStr
|
||||
new_password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class PasswordResetResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
class InvitePreview(BaseModel):
|
||||
work_email: EmailStr
|
||||
first_name: str
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import DashboardPage from "./components/DashboardPage";
|
||||
import ForgotPasswordPage from "./components/ForgotPasswordPage";
|
||||
import InviteAcceptPage from "./components/InviteAcceptPage";
|
||||
import LoginPage from "./components/LoginPage";
|
||||
import MobileLanding from "./components/MobileLanding";
|
||||
|
|
@ -13,6 +14,9 @@ export default function App() {
|
|||
if (window.location.pathname === "/login") {
|
||||
return <LoginPage />;
|
||||
}
|
||||
if (window.location.pathname === "/forgot-password") {
|
||||
return <ForgotPasswordPage />;
|
||||
}
|
||||
if (window.location.pathname.startsWith("/invite/")) {
|
||||
return <InviteAcceptPage />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ async function request(path, { method = "GET", payload, token, basicAuth } = {})
|
|||
|
||||
export const login = (payload) =>
|
||||
request("/auth/login", { method: "POST", payload });
|
||||
export const resetPassword = (payload) =>
|
||||
request("/auth/reset-password", { method: "POST", payload });
|
||||
export const previewInvite = (token) =>
|
||||
request(`/auth/invites/${encodeURIComponent(token)}`);
|
||||
export const acceptInvite = (token, payload) =>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from "react";
|
||||
import { changePassword } from "../api";
|
||||
import PasswordField from "./PasswordField";
|
||||
|
||||
function validateForm(form) {
|
||||
const errors = {};
|
||||
|
|
@ -79,8 +80,7 @@ export default function ChangePasswordModal({ account, onComplete }) {
|
|||
<form className="change-password-form" onSubmit={handleSubmit} noValidate>
|
||||
<label>
|
||||
Current password
|
||||
<input
|
||||
type="password"
|
||||
<PasswordField
|
||||
autoComplete="current-password"
|
||||
value={form.current_password}
|
||||
onChange={(event) => updateField("current_password", event.target.value)}
|
||||
|
|
@ -94,8 +94,7 @@ export default function ChangePasswordModal({ account, onComplete }) {
|
|||
|
||||
<label>
|
||||
New password
|
||||
<input
|
||||
type="password"
|
||||
<PasswordField
|
||||
autoComplete="new-password"
|
||||
value={form.new_password}
|
||||
onChange={(event) => updateField("new_password", event.target.value)}
|
||||
|
|
@ -109,8 +108,7 @@ export default function ChangePasswordModal({ account, onComplete }) {
|
|||
|
||||
<label>
|
||||
Confirm new password
|
||||
<input
|
||||
type="password"
|
||||
<PasswordField
|
||||
autoComplete="new-password"
|
||||
value={form.confirm_password}
|
||||
onChange={(event) => updateField("confirm_password", event.target.value)}
|
||||
|
|
|
|||
95
frontend/src/components/ForgotPasswordPage.jsx
Normal file
95
frontend/src/components/ForgotPasswordPage.jsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { ArrowRight, Mail } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { resetPassword } from "../api";
|
||||
import AuthLayout from "./AuthLayout";
|
||||
import PasswordField from "./PasswordField";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [status, setStatus] = useState({ loading: false, error: "", done: false });
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const password = String(form.get("new_password") || "");
|
||||
const confirm = String(form.get("confirm_password") || "");
|
||||
if (password !== confirm) {
|
||||
setStatus({ loading: false, error: "Passwords do not match.", done: false });
|
||||
return;
|
||||
}
|
||||
setStatus({ loading: true, error: "", done: false });
|
||||
try {
|
||||
await resetPassword({
|
||||
email: form.get("email"),
|
||||
new_password: password,
|
||||
});
|
||||
setStatus({ loading: false, error: "", done: true });
|
||||
} catch (error) {
|
||||
setStatus({ loading: false, error: error.message, done: false });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
eyebrow="Forgot password"
|
||||
title={<>Set a new password<br /><em>and sign back in.</em></>}
|
||||
description="Enter your account email and choose a new password. Extra email or phone checks are skipped for now."
|
||||
>
|
||||
<>
|
||||
<div className="card-heading">
|
||||
<h2>Reset your password</h2>
|
||||
<p>Use the email you sign in with. No verification code is required yet.</p>
|
||||
</div>
|
||||
{status.done ? (
|
||||
<div className="auth-success">
|
||||
<p>Your password has been updated. You can sign in with it now.</p>
|
||||
<a className="button button-coral auth-submit" href="/login">
|
||||
Back to sign in <ArrowRight size={16} />
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Email address
|
||||
<div className="input-wrap">
|
||||
<Mail size={16} />
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="you@gentic.email"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
New password
|
||||
<PasswordField
|
||||
name="new_password"
|
||||
placeholder="8+ characters"
|
||||
minLength={8}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<small className="field-hint">Use uppercase, lowercase, and a number.</small>
|
||||
</label>
|
||||
<label>
|
||||
Confirm new password
|
||||
<PasswordField
|
||||
name="confirm_password"
|
||||
placeholder="Repeat your password"
|
||||
minLength={8}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||
<button className="button button-coral auth-submit" disabled={status.loading}>
|
||||
{status.loading ? "Updating…" : "Update password"} <ArrowRight size={16} />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<p className="auth-switch">Remembered it? <a href="/login">Log in</a></p>
|
||||
</>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { ArrowRight, LockKeyhole } from "lucide-react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { acceptInvite, previewInvite } from "../api";
|
||||
import AuthLayout from "./AuthLayout";
|
||||
import PasswordField from "./PasswordField";
|
||||
|
||||
function inviteTokenFromPath() {
|
||||
const match = window.location.pathname.match(/^\/invite\/([^/]+)/);
|
||||
|
|
@ -89,31 +90,23 @@ export default function InviteAcceptPage() {
|
|||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Password
|
||||
<div className="input-wrap">
|
||||
<LockKeyhole size={16} />
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Create a password"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<PasswordField
|
||||
name="password"
|
||||
placeholder="Create a password"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Confirm password
|
||||
<div className="input-wrap">
|
||||
<LockKeyhole size={16} />
|
||||
<input
|
||||
name="confirm_password"
|
||||
type="password"
|
||||
placeholder="Repeat your password"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<PasswordField
|
||||
name="confirm_password"
|
||||
placeholder="Repeat your password"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<p className="auth-switch" style={{ marginTop: 0 }}>
|
||||
Must include uppercase, lowercase, and a number.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { ArrowRight, LockKeyhole, Mail } from "lucide-react";
|
||||
import { ArrowRight, Mail } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { login } from "../api";
|
||||
import AuthLayout from "./AuthLayout";
|
||||
import PasswordField from "./PasswordField";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [status, setStatus] = useState({ loading: false, error: "" });
|
||||
|
|
@ -33,7 +34,18 @@ export default function LoginPage() {
|
|||
<div className="card-heading"><h2>Log in to agmail</h2><p>Enter your account details below.</p></div>
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<label>Email address<div className="input-wrap"><Mail size={16} /><input name="email" type="email" placeholder="you@gentic.email" required autoComplete="email" /></div></label>
|
||||
<label>Password<div className="input-wrap"><LockKeyhole size={16} /><input name="password" type="password" placeholder="Your password" required autoComplete="current-password" /></div></label>
|
||||
<label>
|
||||
<span className="auth-label-row">
|
||||
Password
|
||||
<a className="auth-forgot" href="/forgot-password">Forgot password?</a>
|
||||
</span>
|
||||
<PasswordField
|
||||
name="password"
|
||||
placeholder="Your password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||
<button className="button button-coral auth-submit" disabled={status.loading}>
|
||||
{status.loading ? "Signing in…" : "Log in"} <ArrowRight size={16} />
|
||||
|
|
|
|||
41
frontend/src/components/PasswordField.jsx
Normal file
41
frontend/src/components/PasswordField.jsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { Eye, EyeOff, LockKeyhole } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function PasswordField({
|
||||
name,
|
||||
placeholder,
|
||||
autoComplete,
|
||||
required,
|
||||
minLength,
|
||||
value,
|
||||
onChange,
|
||||
showLock = true,
|
||||
className = "input-wrap",
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{showLock ? <LockKeyhole size={16} /> : null}
|
||||
<input
|
||||
name={name}
|
||||
type={visible ? "text" : "password"}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
minLength={minLength}
|
||||
autoComplete={autoComplete}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="password-peek"
|
||||
aria-label={visible ? "Hide password" : "Show password"}
|
||||
aria-pressed={visible}
|
||||
onClick={() => setVisible((current) => !current)}
|
||||
>
|
||||
{visible ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import {
|
||||
ArrowRight, Building2, CalendarDays, Eye, LockKeyhole, Mail, Phone, UserRound,
|
||||
ArrowRight, Building2, CalendarDays, Mail, Phone, UserRound,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { register } from "../api";
|
||||
import AuthLayout from "./AuthLayout";
|
||||
import PasswordField from "./PasswordField";
|
||||
|
||||
export default function SignupPage() {
|
||||
const [accountType, setAccountType] = useState("individual");
|
||||
|
|
@ -67,7 +68,7 @@ export default function SignupPage() {
|
|||
</div>
|
||||
<label>Date of birth<div className="input-wrap"><CalendarDays size={16} /><input name="date_of_birth" type="date" required /></div></label>
|
||||
<label>Phone number<div className="input-wrap"><Phone size={16} /><input name="phone_number" type="tel" placeholder="+1 555 123 4567" required /></div></label>
|
||||
<label>Password<div className="input-wrap"><LockKeyhole size={16} /><input name="password" type="password" placeholder="8+ characters" minLength="8" required autoComplete="new-password" /><Eye size={15} /></div><small className="field-hint">Use uppercase, lowercase, and a number.</small></label>
|
||||
<label>Password<PasswordField name="password" placeholder="8+ characters" minLength={8} required autoComplete="new-password" /><small className="field-hint">Use uppercase, lowercase, and a number.</small></label>
|
||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||
<button className="button button-coral auth-submit" disabled={status.loading}>
|
||||
{status.loading ? "Creating account…" : "Create account"} <ArrowRight size={16} />
|
||||
|
|
|
|||
|
|
@ -104,6 +104,11 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
|||
.input-wrap:focus-within { border-color:var(--coral); box-shadow:0 0 0 3px #ff806c18; }
|
||||
.input-wrap input { min-width:0; flex:1; border:0; outline:0; background:transparent; color:var(--ink); font-size:13px; }
|
||||
.input-wrap input::placeholder { color:#aaa5ab; }
|
||||
.password-peek { margin:0; padding:4px; border:0; background:transparent; color:#a19ba2; display:grid; place-items:center; cursor:pointer; }
|
||||
.password-peek:hover,.password-peek:focus-visible { color:var(--ink); }
|
||||
.auth-label-row { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.auth-forgot { color:var(--ink); font-size:11px; font-weight:700; text-decoration:none; }
|
||||
.auth-forgot:hover { color:var(--coral); }
|
||||
.account-types { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
|
||||
.type-card { min-height:74px; padding:13px; display:grid; grid-template-columns:20px 1fr; align-items:center; gap:10px; border:1px solid #e5e0dc; border-radius:12px; background:white; cursor:pointer; }
|
||||
.type-card.selected { border-color:var(--coral); background:#fff6f2; box-shadow:0 0 0 2px #ff806c12; }
|
||||
|
|
@ -499,7 +504,8 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
|||
.change-password-head p { margin:0; font-size:14px; color:#6f6c7b; line-height:1.5; }
|
||||
.change-password-form { display:grid; gap:14px; }
|
||||
.change-password-form label { display:grid; gap:6px; font-size:12px; font-weight:600; color:#4a4652; }
|
||||
.change-password-form input { padding:10px 12px; border:1px solid #E1DFE7; border-radius:10px; font-size:14px; }
|
||||
.change-password-form .input-wrap { height:44px; margin-top:0; padding:0 12px; }
|
||||
.change-password-form .input-wrap input { font-size:14px; }
|
||||
.people-drawer-actions { margin-top:8px; display:flex; justify-content:flex-end; gap:10px; }
|
||||
.group-avatar { width:32px; height:32px; display:grid; place-items:center; border-radius:50%; font-size:12px; font-weight:700; flex-shrink:0; }
|
||||
.group-avatar.tone-green { background:#E8F7F3; color:#2d8a72; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue