@rudderjs/auth 0.2.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 +21 -0
- package/README.md +109 -0
- package/boost/guidelines.md +137 -0
- package/dist/auth-manager.d.ts +40 -0
- package/dist/auth-manager.d.ts.map +1 -0
- package/dist/auth-manager.js +85 -0
- package/dist/auth-manager.js.map +1 -0
- package/dist/contracts.d.ts +27 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +3 -0
- package/dist/contracts.js.map +1 -0
- package/dist/gate.d.ts +49 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +181 -0
- package/dist/gate.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +123 -0
- package/dist/index.js.map +1 -0
- package/dist/password-reset.d.ts +56 -0
- package/dist/password-reset.d.ts.map +1 -0
- package/dist/password-reset.js +101 -0
- package/dist/password-reset.js.map +1 -0
- package/dist/providers.d.ts +20 -0
- package/dist/providers.d.ts.map +1 -0
- package/dist/providers.js +41 -0
- package/dist/providers.js.map +1 -0
- package/dist/session-guard.d.ts +21 -0
- package/dist/session-guard.d.ts.map +1 -0
- package/dist/session-guard.js +52 -0
- package/dist/session-guard.js.map +1 -0
- package/dist/verification.d.ts +58 -0
- package/dist/verification.d.ts.map +1 -0
- package/dist/verification.js +93 -0
- package/dist/verification.js.map +1 -0
- package/package.json +57 -0
- package/pages/react/forgot-password/+Page.tsx +64 -0
- package/pages/react/login/+Page.tsx +70 -0
- package/pages/react/login/+guard.ts +15 -0
- package/pages/react/register/+Page.tsx +78 -0
- package/pages/react/register/+guard.ts +15 -0
- package/pages/react/reset-password/+Page.tsx +118 -0
- package/pages/solid/forgot-password/+Page.tsx +62 -0
- package/pages/solid/login/+Page.tsx +66 -0
- package/pages/solid/login/+guard.ts +15 -0
- package/pages/solid/register/+Page.tsx +72 -0
- package/pages/solid/register/+guard.ts +15 -0
- package/pages/solid/reset-password/+Page.tsx +94 -0
- package/pages/vue/forgot-password/+Page.vue +60 -0
- package/pages/vue/login/+Page.vue +63 -0
- package/pages/vue/login/+guard.ts +15 -0
- package/pages/vue/register/+Page.vue +68 -0
- package/pages/vue/register/+guard.ts +15 -0
- package/pages/vue/reset-password/+Page.vue +93 -0
- package/schema/auth.drizzle.mysql.ts +48 -0
- package/schema/auth.drizzle.pg.ts +48 -0
- package/schema/auth.drizzle.sqlite.ts +48 -0
- package/schema/auth.prisma +50 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export class SessionGuard {
|
|
2
|
+
provider;
|
|
3
|
+
session;
|
|
4
|
+
_user = undefined; // undefined = not loaded yet
|
|
5
|
+
constructor(provider, session) {
|
|
6
|
+
this.provider = provider;
|
|
7
|
+
this.session = session;
|
|
8
|
+
}
|
|
9
|
+
async user() {
|
|
10
|
+
if (this._user !== undefined)
|
|
11
|
+
return this._user;
|
|
12
|
+
const id = this.session.get('auth_user_id');
|
|
13
|
+
if (id) {
|
|
14
|
+
this._user = await this.provider.retrieveById(id);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
this._user = null;
|
|
18
|
+
}
|
|
19
|
+
return this._user;
|
|
20
|
+
}
|
|
21
|
+
async id() {
|
|
22
|
+
const u = await this.user();
|
|
23
|
+
return u ? u.getAuthIdentifier() : null;
|
|
24
|
+
}
|
|
25
|
+
async check() {
|
|
26
|
+
return (await this.user()) !== null;
|
|
27
|
+
}
|
|
28
|
+
async guest() {
|
|
29
|
+
return (await this.user()) === null;
|
|
30
|
+
}
|
|
31
|
+
async attempt(credentials, _remember) {
|
|
32
|
+
const user = await this.provider.retrieveByCredentials(credentials);
|
|
33
|
+
if (!user)
|
|
34
|
+
return false;
|
|
35
|
+
const valid = await this.provider.validateCredentials(user, credentials);
|
|
36
|
+
if (!valid)
|
|
37
|
+
return false;
|
|
38
|
+
await this.login(user);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
async login(user, _remember) {
|
|
42
|
+
await this.session.regenerate();
|
|
43
|
+
this.session.put('auth_user_id', user.getAuthIdentifier());
|
|
44
|
+
this._user = user;
|
|
45
|
+
}
|
|
46
|
+
async logout() {
|
|
47
|
+
this.session.forget('auth_user_id');
|
|
48
|
+
await this.session.regenerate();
|
|
49
|
+
this._user = null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=session-guard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-guard.js","sourceRoot":"","sources":["../src/session-guard.ts"],"names":[],"mappings":"AAYA,MAAM,OAAO,YAAY;IAIJ;IACA;IAJX,KAAK,GAAuC,SAAS,CAAA,CAAC,6BAA6B;IAE3F,YACmB,QAAsB,EACtB,OAAqB;QADrB,aAAQ,GAAR,QAAQ,CAAc;QACtB,YAAO,GAAP,OAAO,CAAc;IACrC,CAAC;IAEJ,KAAK,CAAC,IAAI;QACR,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS;YAAE,OAAO,IAAI,CAAC,KAAK,CAAA;QAE/C,MAAM,EAAE,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAS,cAAc,CAAC,CAAA;QACnD,IAAI,EAAE,EAAE,CAAC;YACP,IAAI,CAAC,KAAK,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,CAAA;QACnD,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,KAAK,GAAG,IAAI,CAAA;QACnB,CAAC;QACD,OAAO,IAAI,CAAC,KAAK,CAAA;IACnB,CAAC;IAED,KAAK,CAAC,EAAE;QACN,MAAM,CAAC,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,CAAA;QAC3B,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,iBAAiB,EAAE,CAAC,CAAC,CAAC,IAAI,CAAA;IACzC,CAAC;IAED,KAAK,CAAC,KAAK;QACT,OAAO,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAA;IACrC,CAAC;IAED,KAAK,CAAC,KAAK;QACT,OAAO,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAA;IACrC,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,WAAoC,EAAE,SAAmB;QACrE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,qBAAqB,CAAC,WAAW,CAAC,CAAA;QACnE,IAAI,CAAC,IAAI;YAAE,OAAO,KAAK,CAAA;QAEvB,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,mBAAmB,CAAC,IAAI,EAAE,WAAW,CAAC,CAAA;QACxE,IAAI,CAAC,KAAK;YAAE,OAAO,KAAK,CAAA;QAExB,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;QACtB,OAAO,IAAI,CAAA;IACb,CAAC;IAED,KAAK,CAAC,KAAK,CAAC,IAAqB,EAAE,SAAmB;QACpD,MAAM,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,CAAA;QAC/B,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,EAAE,IAAI,CAAC,iBAAiB,EAAE,CAAC,CAAA;QAC1D,IAAI,CAAC,KAAK,GAAG,IAAI,CAAA;IACnB,CAAC;IAED,KAAK,CAAC,MAAM;QACV,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,cAAc,CAAC,CAAA;QACnC,MAAM,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,CAAA;QAC/B,IAAI,CAAC,KAAK,GAAG,IAAI,CAAA;IACnB,CAAC;CACF"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from '@rudderjs/contracts';
|
|
2
|
+
import type { Authenticatable } from './contracts.js';
|
|
3
|
+
/**
|
|
4
|
+
* Implement on your User model to opt into email verification.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* class User extends Model implements Authenticatable, MustVerifyEmail {
|
|
8
|
+
* hasVerifiedEmail() { return this.emailVerifiedAt !== null }
|
|
9
|
+
* markEmailAsVerified() { this.emailVerifiedAt = new Date().toISOString(); return Promise.resolve() }
|
|
10
|
+
* getEmailForVerification() { return this.email }
|
|
11
|
+
* }
|
|
12
|
+
*/
|
|
13
|
+
export interface MustVerifyEmail {
|
|
14
|
+
hasVerifiedEmail(): boolean;
|
|
15
|
+
markEmailAsVerified(): Promise<void>;
|
|
16
|
+
getEmailForVerification(): string;
|
|
17
|
+
}
|
|
18
|
+
/** Type guard for users that must verify email. */
|
|
19
|
+
export declare function mustVerifyEmail(user: unknown): user is Authenticatable & MustVerifyEmail;
|
|
20
|
+
/**
|
|
21
|
+
* Middleware that requires the authenticated user to have a verified email.
|
|
22
|
+
* Returns 403 if unverified.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* import { RequireAuth, EnsureEmailIsVerified } from '@rudderjs/auth'
|
|
26
|
+
* router.get('/dashboard', RequireAuth(), EnsureEmailIsVerified(), handler)
|
|
27
|
+
*/
|
|
28
|
+
export declare function EnsureEmailIsVerified(): MiddlewareHandler;
|
|
29
|
+
/**
|
|
30
|
+
* Generate a signed email verification URL for a user.
|
|
31
|
+
* Requires `@rudderjs/router` with a named route 'verification.verify'.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* // Register the verification route:
|
|
35
|
+
* router.get('/email/verify/:id/:hash', verifyHandler, [ValidateSignature()])
|
|
36
|
+
* .name('verification.verify')
|
|
37
|
+
*
|
|
38
|
+
* // Generate the URL (e.g. in a notification):
|
|
39
|
+
* const url = verificationUrl(user)
|
|
40
|
+
*/
|
|
41
|
+
export declare function verificationUrl(user: MustVerifyEmail & {
|
|
42
|
+
id?: string | number;
|
|
43
|
+
getAuthIdentifier?(): string;
|
|
44
|
+
}): string;
|
|
45
|
+
/**
|
|
46
|
+
* Verifies the email hash matches and marks the user as verified.
|
|
47
|
+
* Use inside the verification route handler.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* router.get('/email/verify/:id/:hash', async (req, res) => {
|
|
51
|
+
* await handleEmailVerification(req.params.id, req.params.hash, async (id) => {
|
|
52
|
+
* return User.find(id)
|
|
53
|
+
* })
|
|
54
|
+
* res.json({ message: 'Email verified.' })
|
|
55
|
+
* }, [ValidateSignature()]).name('verification.verify')
|
|
56
|
+
*/
|
|
57
|
+
export declare function handleEmailVerification(id: string, hash: string, findUser: (id: string) => Promise<(MustVerifyEmail & Record<string, unknown>) | null>): Promise<boolean>;
|
|
58
|
+
//# sourceMappingURL=verification.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verification.d.ts","sourceRoot":"","sources":["../src/verification.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AAC5D,OAAO,KAAK,EAAE,eAAe,EAAY,MAAM,gBAAgB,CAAA;AAI/D;;;;;;;;;GASG;AACH,MAAM,WAAW,eAAe;IAC9B,gBAAgB,IAAI,OAAO,CAAA;IAC3B,mBAAmB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACpC,uBAAuB,IAAI,MAAM,CAAA;CAClC;AAED,mDAAmD;AACnD,wBAAgB,eAAe,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,IAAI,eAAe,GAAG,eAAe,CAOxF;AAID;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,IAAI,iBAAiB,CAiBzD;AAID;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,eAAe,GAAG;IAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAAC,iBAAiB,CAAC,IAAI,MAAM,CAAA;CAAE,GAAG,MAAM,CAoBtH;AAID;;;;;;;;;;;GAWG;AACH,wBAAsB,uBAAuB,CAC3C,EAAE,EAAE,MAAM,EACV,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,GACpF,OAAO,CAAC,OAAO,CAAC,CAclB"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Type guard for users that must verify email. */
|
|
2
|
+
export function mustVerifyEmail(user) {
|
|
3
|
+
const u = user;
|
|
4
|
+
return (typeof u['hasVerifiedEmail'] === 'function' &&
|
|
5
|
+
typeof u['markEmailAsVerified'] === 'function' &&
|
|
6
|
+
typeof u['getEmailForVerification'] === 'function');
|
|
7
|
+
}
|
|
8
|
+
// ─── EnsureEmailIsVerified middleware ────────────────────────
|
|
9
|
+
/**
|
|
10
|
+
* Middleware that requires the authenticated user to have a verified email.
|
|
11
|
+
* Returns 403 if unverified.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* import { RequireAuth, EnsureEmailIsVerified } from '@rudderjs/auth'
|
|
15
|
+
* router.get('/dashboard', RequireAuth(), EnsureEmailIsVerified(), handler)
|
|
16
|
+
*/
|
|
17
|
+
export function EnsureEmailIsVerified() {
|
|
18
|
+
return async function EnsureEmailIsVerified(req, res, next) {
|
|
19
|
+
const user = req.user;
|
|
20
|
+
if (!user) {
|
|
21
|
+
res.status(401).json({ message: 'Unauthorized.' });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
// If the user has emailVerifiedAt, they're verified
|
|
25
|
+
if (user['emailVerifiedAt'] !== null && user['emailVerifiedAt'] !== undefined) {
|
|
26
|
+
await next();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
res.status(403).json({ message: 'Your email address is not verified.' });
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
// ─── Verification URL helper ────────────────────────────────
|
|
33
|
+
/**
|
|
34
|
+
* Generate a signed email verification URL for a user.
|
|
35
|
+
* Requires `@rudderjs/router` with a named route 'verification.verify'.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* // Register the verification route:
|
|
39
|
+
* router.get('/email/verify/:id/:hash', verifyHandler, [ValidateSignature()])
|
|
40
|
+
* .name('verification.verify')
|
|
41
|
+
*
|
|
42
|
+
* // Generate the URL (e.g. in a notification):
|
|
43
|
+
* const url = verificationUrl(user)
|
|
44
|
+
*/
|
|
45
|
+
export function verificationUrl(user) {
|
|
46
|
+
let Url;
|
|
47
|
+
try {
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
49
|
+
const mod = require('@rudderjs/router');
|
|
50
|
+
Url = mod.Url;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new Error('[RudderJS Auth] Email verification requires @rudderjs/router for signed URLs.');
|
|
54
|
+
}
|
|
55
|
+
const id = user.getAuthIdentifier?.() ?? String(user['id'] ?? '');
|
|
56
|
+
const email = user.getEmailForVerification();
|
|
57
|
+
// Create a hash of the email for URL validation
|
|
58
|
+
const hash = _sha256(email);
|
|
59
|
+
return Url.temporarySignedRoute('verification.verify', 3600, { id, hash });
|
|
60
|
+
}
|
|
61
|
+
// ─── Verify handler helper ──────────────────────────────────
|
|
62
|
+
/**
|
|
63
|
+
* Verifies the email hash matches and marks the user as verified.
|
|
64
|
+
* Use inside the verification route handler.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* router.get('/email/verify/:id/:hash', async (req, res) => {
|
|
68
|
+
* await handleEmailVerification(req.params.id, req.params.hash, async (id) => {
|
|
69
|
+
* return User.find(id)
|
|
70
|
+
* })
|
|
71
|
+
* res.json({ message: 'Email verified.' })
|
|
72
|
+
* }, [ValidateSignature()]).name('verification.verify')
|
|
73
|
+
*/
|
|
74
|
+
export async function handleEmailVerification(id, hash, findUser) {
|
|
75
|
+
const user = await findUser(id);
|
|
76
|
+
if (!user)
|
|
77
|
+
return false;
|
|
78
|
+
const email = user.getEmailForVerification();
|
|
79
|
+
const expected = _sha256(email);
|
|
80
|
+
if (hash !== expected)
|
|
81
|
+
return false;
|
|
82
|
+
if (!user.hasVerifiedEmail()) {
|
|
83
|
+
await user.markEmailAsVerified();
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
// ─── SHA-256 hash ───────────────────────────────────────────
|
|
88
|
+
function _sha256(input) {
|
|
89
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
90
|
+
const { createHash } = require('node:crypto');
|
|
91
|
+
return createHash('sha256').update(input).digest('hex');
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=verification.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verification.js","sourceRoot":"","sources":["../src/verification.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rudderjs/auth",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/rudderjs/rudder",
|
|
8
|
+
"directory": "packages/auth"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"boost",
|
|
14
|
+
"pages",
|
|
15
|
+
"schema"
|
|
16
|
+
],
|
|
17
|
+
"main": "./dist/index.js",
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"import": "./dist/index.js",
|
|
22
|
+
"types": "./dist/index.d.ts"
|
|
23
|
+
},
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@rudderjs/core": "0.0.8",
|
|
28
|
+
"@rudderjs/contracts": "0.0.3"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@rudderjs/session": "0.0.5",
|
|
32
|
+
"@rudderjs/hash": "0.0.1"
|
|
33
|
+
},
|
|
34
|
+
"peerDependenciesMeta": {
|
|
35
|
+
"@rudderjs/hash": {
|
|
36
|
+
"optional": false
|
|
37
|
+
},
|
|
38
|
+
"@rudderjs/session": {
|
|
39
|
+
"optional": false
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^20.0.0",
|
|
44
|
+
"typescript": "^5.4.0",
|
|
45
|
+
"@rudderjs/hash": "0.0.1",
|
|
46
|
+
"@rudderjs/session": "0.0.5"
|
|
47
|
+
},
|
|
48
|
+
"author": "Suleiman Shahbari",
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsc -p tsconfig.build.json",
|
|
51
|
+
"dev": "tsc -p tsconfig.build.json --watch",
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"lint": "eslint src",
|
|
54
|
+
"test": "tsc -p tsconfig.test.json && node --test dist-test/index.test.js; EXIT=$?; rm -rf dist-test; exit $EXIT",
|
|
55
|
+
"clean": "rm -rf dist"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import '@/index.css'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
|
|
4
|
+
export default function ForgotPasswordPage() {
|
|
5
|
+
const [email, setEmail] = useState('')
|
|
6
|
+
const [error, setError] = useState('')
|
|
7
|
+
const [success, setSuccess] = useState('')
|
|
8
|
+
const [loading, setLoading] = useState(false)
|
|
9
|
+
|
|
10
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
11
|
+
e.preventDefault()
|
|
12
|
+
setError('')
|
|
13
|
+
setSuccess('')
|
|
14
|
+
setLoading(true)
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch('/api/auth/request-password-reset', {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: { 'Content-Type': 'application/json' },
|
|
19
|
+
body: JSON.stringify({ email, redirectTo: '/reset-password' }),
|
|
20
|
+
})
|
|
21
|
+
if (res.ok) {
|
|
22
|
+
setSuccess('If an account exists with that email, a password reset link has been sent.')
|
|
23
|
+
} else {
|
|
24
|
+
const body = await res.json().catch(() => ({})) as { message?: string }
|
|
25
|
+
setError(body.message ?? 'Something went wrong. Please try again.')
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
setError('Something went wrong. Please try again.')
|
|
29
|
+
}
|
|
30
|
+
setLoading(false)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex min-h-svh items-center justify-center p-4">
|
|
35
|
+
<div className="w-full max-w-sm space-y-6">
|
|
36
|
+
<div className="text-center">
|
|
37
|
+
<h1 className="text-2xl font-bold">Forgot password</h1>
|
|
38
|
+
<p className="text-sm text-gray-500 mt-1">Enter your email to receive a reset link</p>
|
|
39
|
+
</div>
|
|
40
|
+
<form onSubmit={handleSubmit} className="space-y-4 rounded-lg border p-6 shadow-sm">
|
|
41
|
+
{error && <p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>}
|
|
42
|
+
{success && <p className="rounded-md bg-green-50 px-3 py-2 text-sm text-green-600">{success}</p>}
|
|
43
|
+
<div>
|
|
44
|
+
<label className="block text-sm font-medium mb-1" htmlFor="email">Email</label>
|
|
45
|
+
<input
|
|
46
|
+
id="email" type="email" placeholder="you@example.com"
|
|
47
|
+
value={email} onChange={e => setEmail(e.currentTarget.value)}
|
|
48
|
+
required autoComplete="email"
|
|
49
|
+
className="w-full rounded-md border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-black"
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
<button type="submit" disabled={loading}
|
|
53
|
+
className="w-full rounded-md bg-black px-4 py-2 text-sm font-medium text-white hover:bg-black/90 disabled:opacity-50">
|
|
54
|
+
{loading ? 'Sending...' : 'Send reset link'}
|
|
55
|
+
</button>
|
|
56
|
+
<p className="text-center text-sm text-gray-500">
|
|
57
|
+
Remember your password?{' '}
|
|
58
|
+
<a href="/login" className="underline hover:text-black">Sign in</a>
|
|
59
|
+
</p>
|
|
60
|
+
</form>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import '@/index.css'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { navigate } from 'vike/client/router'
|
|
4
|
+
|
|
5
|
+
export default function LoginPage() {
|
|
6
|
+
const [email, setEmail] = useState('')
|
|
7
|
+
const [password, setPassword] = useState('')
|
|
8
|
+
const [error, setError] = useState('')
|
|
9
|
+
const [loading, setLoading] = useState(false)
|
|
10
|
+
|
|
11
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
12
|
+
e.preventDefault()
|
|
13
|
+
setError('')
|
|
14
|
+
setLoading(true)
|
|
15
|
+
const res = await fetch('/api/auth/sign-in/email', {
|
|
16
|
+
method: 'POST',
|
|
17
|
+
headers: { 'Content-Type': 'application/json' },
|
|
18
|
+
body: JSON.stringify({ email, password }),
|
|
19
|
+
})
|
|
20
|
+
if (res.ok) {
|
|
21
|
+
const params = new URLSearchParams(window.location.search)
|
|
22
|
+
const redirect = params.get('redirect')
|
|
23
|
+
await navigate(redirect && redirect.startsWith('/') ? redirect : '/')
|
|
24
|
+
} else {
|
|
25
|
+
const body = await res.json().catch(() => ({})) as { message?: string }
|
|
26
|
+
setError(body.message ?? 'Invalid email or password.')
|
|
27
|
+
}
|
|
28
|
+
setLoading(false)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="flex min-h-svh items-center justify-center p-4">
|
|
33
|
+
<div className="w-full max-w-sm space-y-6">
|
|
34
|
+
<div className="text-center">
|
|
35
|
+
<h1 className="text-2xl font-bold">Welcome back</h1>
|
|
36
|
+
<p className="text-sm text-gray-500 mt-1">Sign in to your account</p>
|
|
37
|
+
</div>
|
|
38
|
+
<form onSubmit={handleSubmit} className="space-y-4 rounded-lg border p-6 shadow-sm">
|
|
39
|
+
{error && <p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>}
|
|
40
|
+
<div>
|
|
41
|
+
<label className="block text-sm font-medium mb-1" htmlFor="email">Email</label>
|
|
42
|
+
<input
|
|
43
|
+
id="email" type="email" placeholder="you@example.com"
|
|
44
|
+
value={email} onChange={e => setEmail(e.currentTarget.value)}
|
|
45
|
+
required autoComplete="email"
|
|
46
|
+
className="w-full rounded-md border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-black"
|
|
47
|
+
/>
|
|
48
|
+
</div>
|
|
49
|
+
<div>
|
|
50
|
+
<label className="block text-sm font-medium mb-1" htmlFor="password">Password</label>
|
|
51
|
+
<input
|
|
52
|
+
id="password" type="password" placeholder="••••••••"
|
|
53
|
+
value={password} onChange={e => setPassword(e.currentTarget.value)}
|
|
54
|
+
required autoComplete="current-password"
|
|
55
|
+
className="w-full rounded-md border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-black"
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
<button type="submit" disabled={loading}
|
|
59
|
+
className="w-full rounded-md bg-black px-4 py-2 text-sm font-medium text-white hover:bg-black/90 disabled:opacity-50">
|
|
60
|
+
{loading ? 'Signing in…' : 'Sign in'}
|
|
61
|
+
</button>
|
|
62
|
+
<p className="text-center text-sm text-gray-500">
|
|
63
|
+
Don't have an account?{' '}
|
|
64
|
+
<a href="/register" className="underline hover:text-black">Register</a>
|
|
65
|
+
</p>
|
|
66
|
+
</form>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { redirect } from 'vike/abort'
|
|
2
|
+
import type { GuardAsync } from 'vike/types'
|
|
3
|
+
import type { BetterAuthInstance } from '@rudderjs/auth'
|
|
4
|
+
|
|
5
|
+
export const guard: GuardAsync = async (pageContext): ReturnType<GuardAsync> => {
|
|
6
|
+
// import.meta.env.SSR is a Vite compile-time constant — tree-shaken from client bundle
|
|
7
|
+
if (!import.meta.env.SSR) return
|
|
8
|
+
const { app } = await import('@rudderjs/core')
|
|
9
|
+
const auth = app().make<BetterAuthInstance>('auth')
|
|
10
|
+
const session = await auth.api.getSession({
|
|
11
|
+
headers: new Headers(pageContext.headers ?? {}),
|
|
12
|
+
})
|
|
13
|
+
// Already logged in — redirect to home
|
|
14
|
+
if (session?.user) throw redirect('/')
|
|
15
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import '@/index.css'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { navigate } from 'vike/client/router'
|
|
4
|
+
|
|
5
|
+
export default function RegisterPage() {
|
|
6
|
+
const [name, setName] = useState('')
|
|
7
|
+
const [email, setEmail] = useState('')
|
|
8
|
+
const [password, setPassword] = useState('')
|
|
9
|
+
const [error, setError] = useState('')
|
|
10
|
+
const [loading, setLoading] = useState(false)
|
|
11
|
+
|
|
12
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
13
|
+
e.preventDefault()
|
|
14
|
+
setError('')
|
|
15
|
+
setLoading(true)
|
|
16
|
+
const res = await fetch('/api/auth/sign-up/email', {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: { 'Content-Type': 'application/json' },
|
|
19
|
+
body: JSON.stringify({ name, email, password }),
|
|
20
|
+
})
|
|
21
|
+
if (res.ok) {
|
|
22
|
+
await navigate('/')
|
|
23
|
+
} else {
|
|
24
|
+
const body = await res.json().catch(() => ({})) as { message?: string }
|
|
25
|
+
setError(body.message ?? 'Could not create account. Please try again.')
|
|
26
|
+
}
|
|
27
|
+
setLoading(false)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="flex min-h-svh items-center justify-center p-4">
|
|
32
|
+
<div className="w-full max-w-sm space-y-6">
|
|
33
|
+
<div className="text-center">
|
|
34
|
+
<h1 className="text-2xl font-bold">Create an account</h1>
|
|
35
|
+
<p className="text-sm text-gray-500 mt-1">Get started in seconds</p>
|
|
36
|
+
</div>
|
|
37
|
+
<form onSubmit={handleSubmit} className="space-y-4 rounded-lg border p-6 shadow-sm">
|
|
38
|
+
{error && <p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>}
|
|
39
|
+
<div>
|
|
40
|
+
<label className="block text-sm font-medium mb-1" htmlFor="name">Name</label>
|
|
41
|
+
<input
|
|
42
|
+
id="name" type="text" placeholder="Alice Smith"
|
|
43
|
+
value={name} onChange={e => setName(e.currentTarget.value)}
|
|
44
|
+
required autoComplete="name"
|
|
45
|
+
className="w-full rounded-md border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-black"
|
|
46
|
+
/>
|
|
47
|
+
</div>
|
|
48
|
+
<div>
|
|
49
|
+
<label className="block text-sm font-medium mb-1" htmlFor="email">Email</label>
|
|
50
|
+
<input
|
|
51
|
+
id="email" type="email" placeholder="you@example.com"
|
|
52
|
+
value={email} onChange={e => setEmail(e.currentTarget.value)}
|
|
53
|
+
required autoComplete="email"
|
|
54
|
+
className="w-full rounded-md border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-black"
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
<div>
|
|
58
|
+
<label className="block text-sm font-medium mb-1" htmlFor="password">Password</label>
|
|
59
|
+
<input
|
|
60
|
+
id="password" type="password" placeholder="••••••••"
|
|
61
|
+
value={password} onChange={e => setPassword(e.currentTarget.value)}
|
|
62
|
+
required autoComplete="new-password" minLength={8}
|
|
63
|
+
className="w-full rounded-md border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-black"
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
<button type="submit" disabled={loading}
|
|
67
|
+
className="w-full rounded-md bg-black px-4 py-2 text-sm font-medium text-white hover:bg-black/90 disabled:opacity-50">
|
|
68
|
+
{loading ? 'Creating account…' : 'Create account'}
|
|
69
|
+
</button>
|
|
70
|
+
<p className="text-center text-sm text-gray-500">
|
|
71
|
+
Already have an account?{' '}
|
|
72
|
+
<a href="/login" className="underline hover:text-black">Sign in</a>
|
|
73
|
+
</p>
|
|
74
|
+
</form>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { redirect } from 'vike/abort'
|
|
2
|
+
import type { GuardAsync } from 'vike/types'
|
|
3
|
+
import type { BetterAuthInstance } from '@rudderjs/auth'
|
|
4
|
+
|
|
5
|
+
export const guard: GuardAsync = async (pageContext): ReturnType<GuardAsync> => {
|
|
6
|
+
// import.meta.env.SSR is a Vite compile-time constant — tree-shaken from client bundle
|
|
7
|
+
if (!import.meta.env.SSR) return
|
|
8
|
+
const { app } = await import('@rudderjs/core')
|
|
9
|
+
const auth = app().make<BetterAuthInstance>('auth')
|
|
10
|
+
const session = await auth.api.getSession({
|
|
11
|
+
headers: new Headers(pageContext.headers ?? {}),
|
|
12
|
+
})
|
|
13
|
+
// Already registered and logged in — redirect to home
|
|
14
|
+
if (session?.user) throw redirect('/')
|
|
15
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import '@/index.css'
|
|
2
|
+
import { useState, useEffect } from 'react'
|
|
3
|
+
|
|
4
|
+
export default function ResetPasswordPage() {
|
|
5
|
+
const [password, setPassword] = useState('')
|
|
6
|
+
const [confirmPassword, setConfirm] = useState('')
|
|
7
|
+
const [error, setError] = useState('')
|
|
8
|
+
const [success, setSuccess] = useState('')
|
|
9
|
+
const [loading, setLoading] = useState(false)
|
|
10
|
+
const [token, setToken] = useState<string | null>(null)
|
|
11
|
+
const [mounted, setMounted] = useState(false)
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const params = new URLSearchParams(window.location.search)
|
|
15
|
+
setToken(params.get('token'))
|
|
16
|
+
setMounted(true)
|
|
17
|
+
}, [])
|
|
18
|
+
|
|
19
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
20
|
+
e.preventDefault()
|
|
21
|
+
setError('')
|
|
22
|
+
setSuccess('')
|
|
23
|
+
|
|
24
|
+
if (password !== confirmPassword) {
|
|
25
|
+
setError('Passwords do not match.')
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
setLoading(true)
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch('/api/auth/reset-password', {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
body: JSON.stringify({ token, newPassword: password }),
|
|
35
|
+
})
|
|
36
|
+
if (res.ok) {
|
|
37
|
+
setSuccess('Your password has been reset successfully.')
|
|
38
|
+
} else {
|
|
39
|
+
const body = await res.json().catch(() => ({})) as { message?: string }
|
|
40
|
+
setError(body.message ?? 'Invalid or expired token.')
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
setError('Something went wrong. Please try again.')
|
|
44
|
+
}
|
|
45
|
+
setLoading(false)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!mounted) {
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex min-h-svh items-center justify-center p-4">
|
|
51
|
+
<div className="text-sm text-gray-500">Loading...</div>
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!token) {
|
|
57
|
+
return (
|
|
58
|
+
<div className="flex min-h-svh items-center justify-center p-4">
|
|
59
|
+
<div className="w-full max-w-sm space-y-6">
|
|
60
|
+
<div className="space-y-4 rounded-lg border p-6 shadow-sm">
|
|
61
|
+
<p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">Missing reset token.</p>
|
|
62
|
+
<p className="text-center text-sm text-gray-500">
|
|
63
|
+
<a href="/forgot-password" className="underline hover:text-black">Request a new reset link</a>
|
|
64
|
+
</p>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="flex min-h-svh items-center justify-center p-4">
|
|
73
|
+
<div className="w-full max-w-sm space-y-6">
|
|
74
|
+
<div className="text-center">
|
|
75
|
+
<h1 className="text-2xl font-bold">Reset password</h1>
|
|
76
|
+
<p className="text-sm text-gray-500 mt-1">Enter your new password</p>
|
|
77
|
+
</div>
|
|
78
|
+
<form onSubmit={handleSubmit} className="space-y-4 rounded-lg border p-6 shadow-sm">
|
|
79
|
+
{error && <p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>}
|
|
80
|
+
{success && (
|
|
81
|
+
<div className="space-y-2">
|
|
82
|
+
<p className="rounded-md bg-green-50 px-3 py-2 text-sm text-green-600">{success}</p>
|
|
83
|
+
<p className="text-center text-sm text-gray-500">
|
|
84
|
+
<a href="/login" className="underline hover:text-black">Sign in</a>
|
|
85
|
+
</p>
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
{!success && (
|
|
89
|
+
<>
|
|
90
|
+
<div>
|
|
91
|
+
<label className="block text-sm font-medium mb-1" htmlFor="password">New password</label>
|
|
92
|
+
<input
|
|
93
|
+
id="password" type="password" placeholder="••••••••"
|
|
94
|
+
value={password} onChange={e => setPassword(e.currentTarget.value)}
|
|
95
|
+
required minLength={8} autoComplete="new-password"
|
|
96
|
+
className="w-full rounded-md border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-black"
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
<div>
|
|
100
|
+
<label className="block text-sm font-medium mb-1" htmlFor="confirm-password">Confirm password</label>
|
|
101
|
+
<input
|
|
102
|
+
id="confirm-password" type="password" placeholder="••••••••"
|
|
103
|
+
value={confirmPassword} onChange={e => setConfirm(e.currentTarget.value)}
|
|
104
|
+
required minLength={8} autoComplete="new-password"
|
|
105
|
+
className="w-full rounded-md border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-black"
|
|
106
|
+
/>
|
|
107
|
+
</div>
|
|
108
|
+
<button type="submit" disabled={loading}
|
|
109
|
+
className="w-full rounded-md bg-black px-4 py-2 text-sm font-medium text-white hover:bg-black/90 disabled:opacity-50">
|
|
110
|
+
{loading ? 'Resetting...' : 'Reset password'}
|
|
111
|
+
</button>
|
|
112
|
+
</>
|
|
113
|
+
)}
|
|
114
|
+
</form>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|