mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-13 10:23:12 -05:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fec1118a02 | ||
|
|
444340624a | ||
|
|
32a6a70005 | ||
|
|
ffa6e1a61e | ||
|
|
3c5b7cc9cb | ||
|
|
f383519d16 | ||
|
|
ac0e95c265 | ||
|
|
885fc0a983 | ||
|
|
97215d3a3e | ||
|
|
68912622d9 | ||
|
|
03ab6b8f11 | ||
|
|
314dd37ce8 | ||
|
|
74571c9f47 | ||
|
|
e70df9a72f | ||
|
|
9982d49f99 |
@@ -233,8 +233,8 @@ jobs:
|
||||
with:
|
||||
projectPath: ./crates/desktop/artcraft
|
||||
tagName: artcraft-v__VERSION__ # the action automatically replaces __VERSION__ with the app version.
|
||||
releaseName: "Artcraft v__VERSION__"
|
||||
releaseBody: "See the assets to download this version and install."
|
||||
releaseName: "ArtCraft v__VERSION__"
|
||||
releaseBody: "See the assets below to download this version and install."
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
@@ -99,9 +99,9 @@ jobs:
|
||||
SQLX_OFFLINE: true
|
||||
with:
|
||||
projectPath: ./crates/desktop/artcraft
|
||||
tagName: artcraft-windows-v__VERSION__ # the action automatically replaces __VERSION__ with the app version.
|
||||
releaseName: "Artcraft Windows v__VERSION__"
|
||||
releaseBody: "Windows build - See the assets to download this version and install."
|
||||
tagName: artcraft-v__VERSION__ # the action automatically replaces __VERSION__ with the app version.
|
||||
releaseName: "ArtCraft v__VERSION__"
|
||||
releaseBody: "Production build of ArtCraft. See the assets below to download and install."
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
@@ -32,6 +32,9 @@ script/production_database_secret_backup.sh
|
||||
script/production_database_secret_connect.sh
|
||||
script/socat_dev_database_connect.sh
|
||||
|
||||
# Python virtual envs (named by convention)
|
||||
venv/
|
||||
|
||||
# Scratch data
|
||||
scratch/
|
||||
test_data/temp/
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE users\nSET\n email_confirmed = true,\n email_is_synthetic = false,\n is_without_password = false,\n is_temporary = false,\n password_hash = ?,\n ip_address_last_update = ?,\n password_version = password_version + 1,\n version = version + 1\nWHERE\n token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9cd3a581f6857e5c1db68a103f47eba9714ee4b5eefc8ba02fc49a4b3a19310e"
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE users\nSET\n email_confirmed = true,\n password_hash = ?,\n ip_address_last_update = ?,\n is_without_password = false,\n password_version = password_version + 1,\n version = version + 1\nWHERE\n token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b8795466b4251d869bdcc644324c6a873791ad1ef0d6d43f77ad2569accec3a4"
|
||||
}
|
||||
Generated
+1
-1
@@ -692,7 +692,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "artcraft"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"artcraft_api_defs",
|
||||
|
||||
@@ -86,6 +86,13 @@ diesel migration run
|
||||
You might get a scary message about `"Encountered unknown type for Mysql: enum"` -- you can safely ignore this
|
||||
error if you see it in isolation. It doesn't impact the migrations whatsoever.
|
||||
|
||||
Finally, you'll need the sqlx CLI tool to run codegen.
|
||||
You likely won't need this now, but if you change any queries, this will be necessary:
|
||||
|
||||
```bash
|
||||
cargo install sqlx-cli --features rustls,mysql,sqlite
|
||||
```
|
||||
|
||||
### Install Redis (Mac)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "artcraft"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
description = "Artcraft"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
|
||||
/// This should match the CI builds.
|
||||
pub const ARTCRAFT_VERSION: &str = "0.5.0";
|
||||
pub const ARTCRAFT_VERSION: &str = "0.6.0";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
|
||||
"productName": "ArtCraft",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"identifier": "ai.artcraft.app",
|
||||
"build": {
|
||||
"frontendDist": "../../../frontend/apps/artcraft/dist",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
|
||||
"productName": "ArtCraft",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"identifier": "ai.artcraft.app",
|
||||
"build": {
|
||||
"frontendDist": "../../../frontend/apps/artcraft/dist",
|
||||
|
||||
@@ -14,7 +14,7 @@ pub enum PasswordResetLocation {
|
||||
|
||||
pub fn get_password_reset_url(reset_token: &str, location: PasswordResetLocation) -> String {
|
||||
let url = match location {
|
||||
PasswordResetLocation::ArtCraftProduction => "https://getartcraft.com/password-reset/verify",
|
||||
PasswordResetLocation::ArtCraftProduction => "https://getartcraft.com/forgot-password/verify",
|
||||
PasswordResetLocation::FakeYouProduction => "https://fakeyou.com/password-reset/verify",
|
||||
PasswordResetLocation::ArtCraftDevelopment |
|
||||
PasswordResetLocation::FakeYouDevelopment => {
|
||||
|
||||
@@ -56,6 +56,14 @@
|
||||
/signup/ /index.html 200
|
||||
/signup/* /index.html 200
|
||||
|
||||
/forgot-password /index.html 200
|
||||
/forgot-password/ /index.html 200
|
||||
/forgot-password/* /index.html 200
|
||||
|
||||
/welcome /index.html 200
|
||||
/welcome/ /index.html 200
|
||||
/welcome/* /index.html 200
|
||||
|
||||
/password-reset /index.html 200
|
||||
/password-reset/ /index.html 200
|
||||
/password-reset/* /index.html 200
|
||||
|
||||
@@ -13,7 +13,7 @@ import NewsPost from "../pages/news/news-post";
|
||||
import Pricing from "../pages/pricing";
|
||||
import Login from "../pages/login";
|
||||
import Signup from "../pages/signup";
|
||||
import ForgotPassword from "../pages/forgot-password";
|
||||
import ForgotPassword, { VerifyReset } from "../pages/forgot-password";
|
||||
import Welcome from "../pages/welcome";
|
||||
import Onboarding from "../pages/onboarding";
|
||||
import { CheckoutSuccess, CheckoutCancel } from "../pages/checkout";
|
||||
@@ -39,6 +39,7 @@ export function App() {
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/forgot-password/verify" element={<VerifyReset />} />
|
||||
<Route path="/welcome" element={<Welcome />} />
|
||||
<Route path="/onboarding" element={<Onboarding />} />
|
||||
<Route path="/checkout/success" element={<CheckoutSuccess />} />
|
||||
|
||||
@@ -204,26 +204,45 @@ export default function Navbar() {
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
<Button
|
||||
as="link"
|
||||
href="/signup"
|
||||
className="bg-white text-black hover:bg-white/90 text-sm font-semibold px-4 py-2 h-auto rounded-lg shadow-md"
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Link to="/login">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="bg-white/80 text-black hover:bg-white/90 text-sm font-semibold px-4 py-2 rounded-lg shadow-md"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/signup">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="text-sm font-semibold px-4 py-2 rounded-lg shadow-md"
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Button - simplified for now */}
|
||||
<div className="-ml-2 flex items-center md:hidden gap-3">
|
||||
{!user && (
|
||||
<Button
|
||||
as="link"
|
||||
href="/signup"
|
||||
className="bg-white text-black hover:bg-white/90 text-xs font-semibold px-3 py-1.5 h-auto rounded-lg"
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Link to="/login">
|
||||
<Button className="bg-white/60 text-black hover:bg-white/90 text-xs font-semibold px-3 py-1.5 h-auto rounded-lg">
|
||||
Login
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/signup">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="text-xs font-semibold px-3 py-1.5 h-auto"
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{user && (
|
||||
<img
|
||||
|
||||
@@ -1,15 +1,55 @@
|
||||
import { faArrowLeft, faEnvelope } from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
faArrowLeft,
|
||||
faEnvelope,
|
||||
faSpinner,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { PasswordResetApi } from "@storyteller/api";
|
||||
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
const ForgotPassword = () => {
|
||||
const [email, setEmail] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const handleRequestReset = async () => {
|
||||
setError(null);
|
||||
|
||||
if (!email.trim()) {
|
||||
setError("Please enter your email or username.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const api = new PasswordResetApi();
|
||||
const response = await api.RequestPasswordReset({
|
||||
usernameOrEmail: email.trim(),
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (response.success) {
|
||||
setSubmitted(true);
|
||||
} else {
|
||||
setError(
|
||||
response.errorMessage ||
|
||||
"Failed to send reset email. Please try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleRequestReset();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
|
||||
<Seo
|
||||
@@ -20,45 +60,66 @@ const ForgotPassword = () => {
|
||||
|
||||
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold mb-2">Reset Password</h1>
|
||||
<p className="text-white/60 text-sm">
|
||||
Enter your email to receive reset instructions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!submitted ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
/>
|
||||
<>
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold mb-2">Reset Password</h1>
|
||||
<p className="text-white/60 text-sm">
|
||||
Enter your email to receive reset instructions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
|
||||
onClick={() => setSubmitted(true)}
|
||||
>
|
||||
Send Reset Link
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Email or Username
|
||||
</label>
|
||||
<input
|
||||
id="reset-email"
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="you@example.com"
|
||||
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
id="send-reset-btn"
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
|
||||
onClick={handleRequestReset}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
) : (
|
||||
"Send Reset Code"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4 text-green-500">
|
||||
<FontAwesomeIcon icon={faEnvelope} className="text-2xl" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-2">Check your email</h3>
|
||||
<p className="text-white/60 text-sm mb-8">
|
||||
We've sent a password reset link to <br />
|
||||
<p className="text-white/60 text-sm mb-6">
|
||||
We've sent a password reset code to <br />
|
||||
<span className="text-white font-medium">{email}</span>
|
||||
</p>
|
||||
<Link to="/forgot-password/verify">
|
||||
<Button className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mb-3">
|
||||
Enter Verification Code
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
className="w-full bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-12"
|
||||
onClick={() => setSubmitted(false)}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
import ForgotPassword from "./forgot-password";
|
||||
import VerifyReset from "./verify-reset";
|
||||
export default ForgotPassword;
|
||||
export { VerifyReset };
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
faArrowLeft,
|
||||
faLock,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faSpinner,
|
||||
faKey,
|
||||
faCheckCircle,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { PasswordResetApi } from "@storyteller/api";
|
||||
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
const VerifyReset = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Pre-fill token from URL query param if present
|
||||
const tokenFromUrl = searchParams.get("token") || "";
|
||||
|
||||
const [verificationCode, setVerificationCode] = useState(tokenFromUrl);
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleRedeemReset = async () => {
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
|
||||
// Client-side validation
|
||||
const errors: Record<string, string> = {};
|
||||
if (!verificationCode.trim()) {
|
||||
errors.verificationCode = "Verification code is required.";
|
||||
}
|
||||
if (!newPassword) {
|
||||
errors.newPassword = "New password is required.";
|
||||
} else if (newPassword.length < 8) {
|
||||
errors.newPassword = "Password must be at least 8 characters.";
|
||||
}
|
||||
if (!confirmPassword) {
|
||||
errors.confirmPassword = "Please confirm your new password.";
|
||||
} else if (newPassword !== confirmPassword) {
|
||||
errors.confirmPassword = "Passwords do not match.";
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFieldErrors(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const api = new PasswordResetApi();
|
||||
const response = await api.RedeemPasswordReset({
|
||||
resetToken: verificationCode.trim(),
|
||||
newPassword: newPassword,
|
||||
newPasswordValidation: confirmPassword,
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (response.success) {
|
||||
setSuccess(true);
|
||||
} else {
|
||||
setError(
|
||||
response.errorMessage ||
|
||||
"Failed to reset password. Please check your code and try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleRedeemReset();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
|
||||
<Seo
|
||||
title="Verify Password Reset - ArtCraft"
|
||||
description="Enter your verification code and new password."
|
||||
/>
|
||||
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
|
||||
|
||||
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
|
||||
{!success ? (
|
||||
<>
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold mb-2">
|
||||
Password Reset Verification
|
||||
</h1>
|
||||
<p className="text-white/60 text-sm">
|
||||
Enter the code sent to your email address.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verification Code */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Verification Code
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon
|
||||
icon={faKey}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/20"
|
||||
/>
|
||||
<input
|
||||
id="verification-code"
|
||||
type="text"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
placeholder="Enter verification code"
|
||||
className={`w-full bg-black/20 border ${
|
||||
fieldErrors.verificationCode
|
||||
? "border-red-500/50"
|
||||
: "border-white/10"
|
||||
} focus:border-primary/50 rounded-xl pl-11 pr-4 py-3 text-white placeholder-white/20 outline-none transition-colors`}
|
||||
/>
|
||||
</div>
|
||||
{fieldErrors.verificationCode && (
|
||||
<p className="text-red-400 text-xs ml-1 mt-1">
|
||||
{fieldErrors.verificationCode}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Password */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
New Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon
|
||||
icon={faLock}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/20"
|
||||
/>
|
||||
<input
|
||||
id="new-password"
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Enter new password"
|
||||
className={`w-full bg-black/20 border ${
|
||||
fieldErrors.newPassword
|
||||
? "border-red-500/50"
|
||||
: "border-white/10"
|
||||
} focus:border-primary/50 rounded-xl pl-11 pr-12 py-3 text-white placeholder-white/20 outline-none transition-colors`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={showNewPassword ? faEyeSlash : faEye}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{fieldErrors.newPassword && (
|
||||
<p className="text-red-400 text-xs ml-1 mt-1">
|
||||
{fieldErrors.newPassword}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Verify New Password */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
|
||||
Verify New Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<FontAwesomeIcon
|
||||
icon={faLock}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/20"
|
||||
/>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter new password again"
|
||||
className={`w-full bg-black/20 border ${
|
||||
fieldErrors.confirmPassword
|
||||
? "border-red-500/50"
|
||||
: "border-white/10"
|
||||
} focus:border-primary/50 rounded-xl pl-11 pr-12 py-3 text-white placeholder-white/20 outline-none transition-colors`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setShowConfirmPassword(!showConfirmPassword)
|
||||
}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={showConfirmPassword ? faEyeSlash : faEye}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{fieldErrors.confirmPassword && (
|
||||
<p className="text-red-400 text-xs ml-1 mt-1">
|
||||
{fieldErrors.confirmPassword}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
id="change-password-btn"
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
|
||||
onClick={handleRedeemReset}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
) : (
|
||||
"Change Password"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center text-sm">
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-white/40 hover:text-white transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowLeft} /> Request a new code
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4 text-green-500">
|
||||
<FontAwesomeIcon icon={faCheckCircle} className="text-2xl" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-2">
|
||||
Password Reset Successfully
|
||||
</h3>
|
||||
<p className="text-white/60 text-sm mb-8">
|
||||
Your password has been changed. You can now log in with your
|
||||
new password.
|
||||
</p>
|
||||
<Button
|
||||
id="go-to-login-btn"
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12"
|
||||
onClick={() => navigate("/login")}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifyReset;
|
||||
@@ -11,6 +11,7 @@ export * from "./lib/LocalApi.js";
|
||||
export * from "./lib/PromptsApi.js";
|
||||
export * from "./lib/TtsApi.js";
|
||||
export * from "./lib/UserBookmarksApi.js";
|
||||
export * from "./lib/PasswordResetApi.js";
|
||||
export * from "./lib/UsersApi.js";
|
||||
export * from "./lib/VideoApi.js";
|
||||
export * from "./lib/WeightsApi.js";
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ApiManager, ApiResponse } from "./ApiManager.js";
|
||||
|
||||
export class PasswordResetApi extends ApiManager {
|
||||
public async RequestPasswordReset({
|
||||
usernameOrEmail,
|
||||
}: {
|
||||
usernameOrEmail: string;
|
||||
}): Promise<ApiResponse<null>> {
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/password_reset/request`;
|
||||
const body = {
|
||||
username_or_email: usernameOrEmail,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.post<
|
||||
{ username_or_email: string },
|
||||
{
|
||||
success: boolean;
|
||||
error_type?: string;
|
||||
error_fields?: Record<string, string>;
|
||||
error_message?: string;
|
||||
}
|
||||
>({
|
||||
endpoint: endpoint,
|
||||
body: body,
|
||||
});
|
||||
|
||||
return {
|
||||
success: response.success,
|
||||
errorMessage:
|
||||
response.error_message ||
|
||||
(response.error_fields
|
||||
? Object.values(response.error_fields).join(", ")
|
||||
: undefined),
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async RedeemPasswordReset({
|
||||
resetToken,
|
||||
newPassword,
|
||||
newPasswordValidation,
|
||||
}: {
|
||||
resetToken: string;
|
||||
newPassword: string;
|
||||
newPasswordValidation: string;
|
||||
}): Promise<ApiResponse<null>> {
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/password_reset/redeem`;
|
||||
const body = {
|
||||
reset_token: resetToken,
|
||||
new_password: newPassword,
|
||||
new_password_validation: newPasswordValidation,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.post<
|
||||
{
|
||||
reset_token: string;
|
||||
new_password: string;
|
||||
new_password_validation: string;
|
||||
},
|
||||
{
|
||||
success: boolean;
|
||||
error_type?: string;
|
||||
error_fields?: Record<string, string>;
|
||||
error_message?: string;
|
||||
}
|
||||
>({
|
||||
endpoint: endpoint,
|
||||
body: body,
|
||||
});
|
||||
|
||||
return {
|
||||
success: response.success,
|
||||
errorMessage:
|
||||
response.error_message ||
|
||||
(response.error_fields
|
||||
? Object.values(response.error_fields).join(", ")
|
||||
: undefined),
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database migration script for running queries in a loop with failure fallback.
|
||||
Reads MySQL credentials from a TOML secrets file.
|
||||
|
||||
Requirements:
|
||||
pip install pymysql
|
||||
|
||||
Secrets file (script/database/secrets.toml):
|
||||
[database]
|
||||
url = "mysql://user:password@host:3306/dbname"
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import tomllib
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
import pymysql
|
||||
except ImportError:
|
||||
print("pymysql is required: pip install pymysql")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
SECRETS_PATH = "secrets.toml"
|
||||
|
||||
# -- Migration query to run in batches --
|
||||
# Tweak this as needed. Use LIMIT to control batch size.
|
||||
QUERY = """
|
||||
delete
|
||||
from media_files
|
||||
where maybe_creator_user_token is null
|
||||
limit 10000
|
||||
"""
|
||||
|
||||
BATCH_PAUSE_SECONDS = 0.5
|
||||
MAX_CONSECUTIVE_FAILURES = 5
|
||||
FAILURE_BACKOFF_SECONDS = 5
|
||||
|
||||
|
||||
def load_config():
|
||||
with open(SECRETS_PATH, "rb") as f:
|
||||
config = tomllib.load(f)
|
||||
url = config["database"]["url"]
|
||||
parsed = urlparse(url)
|
||||
return {
|
||||
"host": parsed.hostname,
|
||||
"port": parsed.port or 3306,
|
||||
"user": parsed.username,
|
||||
"password": parsed.password,
|
||||
"database": parsed.path.lstrip("/"),
|
||||
}
|
||||
|
||||
|
||||
def connect(config):
|
||||
return pymysql.connect(
|
||||
host=config["host"],
|
||||
port=config["port"],
|
||||
user=config["user"],
|
||||
password=config["password"],
|
||||
database=config["database"],
|
||||
connect_timeout=10,
|
||||
read_timeout=30,
|
||||
write_timeout=30,
|
||||
autocommit=True,
|
||||
)
|
||||
|
||||
|
||||
def run_migration():
|
||||
config = load_config()
|
||||
conn = connect(config)
|
||||
total_affected = 0
|
||||
failures = 0
|
||||
start_time = time.monotonic()
|
||||
|
||||
print(f"Connected to {config['host']}/{config['database']}")
|
||||
print(f"Running query:\n{QUERY.strip()}\n")
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
batch_start = time.monotonic()
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(QUERY)
|
||||
affected = cursor.rowcount
|
||||
|
||||
total_affected += affected
|
||||
failures = 0
|
||||
batch_time = int(time.monotonic() - batch_start)
|
||||
elapsed = int(time.monotonic() - start_time)
|
||||
now = datetime.now().strftime("%H:%M:%S")
|
||||
print(f" Batch: {affected} rows {batch_time}s | Total: {total_affected} rows {elapsed}s | Clock: {now}")
|
||||
|
||||
if affected == 0:
|
||||
print("No more rows to process. Done.")
|
||||
break
|
||||
|
||||
time.sleep(BATCH_PAUSE_SECONDS)
|
||||
|
||||
except (pymysql.OperationalError, pymysql.InterfaceError) as e:
|
||||
failures += 1
|
||||
print(f" Error ({failures}/{MAX_CONSECUTIVE_FAILURES}): {e}")
|
||||
|
||||
if failures >= MAX_CONSECUTIVE_FAILURES:
|
||||
print("Too many consecutive failures. Aborting.")
|
||||
sys.exit(1)
|
||||
|
||||
print(f" Reconnecting in {FAILURE_BACKOFF_SECONDS}s...")
|
||||
time.sleep(FAILURE_BACKOFF_SECONDS)
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
conn = connect(config)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(f"\nInterrupted. Total rows affected: {total_affected}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
print(f"Finished. Total rows affected: {total_affected}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_migration()
|
||||
@@ -0,0 +1,2 @@
|
||||
[database]
|
||||
url = "mysql://user:password@host:3306/dbname"
|
||||
Reference in New Issue
Block a user