autherr 1.0.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/README.md +0 -0
- package/dist/api/logout.d.ts +1 -0
- package/dist/api/logout.js +6 -0
- package/dist/api/session.d.ts +2 -0
- package/dist/api/session.js +66 -0
- package/dist/components/LoginButton.d.ts +7 -0
- package/dist/components/LoginButton.js +8 -0
- package/dist/components/SignupButton.d.ts +7 -0
- package/dist/components/SignupButton.js +8 -0
- package/dist/components/UserMenu.d.ts +1 -0
- package/dist/components/UserMenu.js +79 -0
- package/dist/hooks/useAutherr.d.ts +10 -0
- package/dist/hooks/useAutherr.js +14 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/provider/AutherrProvider.d.ts +20 -0
- package/dist/provider/AutherrProvider.js +70 -0
- package/dist/types/auth.d.ts +12 -0
- package/dist/types/auth.js +1 -0
- package/package.json +17 -0
- package/src/api/logout.ts +12 -0
- package/src/api/session.ts +91 -0
- package/src/components/LoginButton.tsx +26 -0
- package/src/components/SignupButton.tsx +26 -0
- package/src/components/UserMenu.tsx +158 -0
- package/src/hooks/useAutherr.ts +25 -0
- package/src/index.ts +6 -0
- package/src/provider/AutherrProvider.tsx +114 -0
- package/src/types/auth.ts +13 -0
- package/tsconfig.json +17 -0
package/README.md
ADDED
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function logoutSession(baseUrl: string, clientId: string): Promise<void>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export async function fetchSession(baseUrl, clientId) {
|
|
2
|
+
const res = await fetch(`${baseUrl}/__bff/auth/session?client_id=${clientId}`, {
|
|
3
|
+
method: "GET",
|
|
4
|
+
credentials: "include", // REQUIRED for refresh cookie
|
|
5
|
+
headers: {
|
|
6
|
+
"Content-Type": "application/json",
|
|
7
|
+
},
|
|
8
|
+
});
|
|
9
|
+
if (!res.ok) {
|
|
10
|
+
return { authenticated: false };
|
|
11
|
+
}
|
|
12
|
+
const json = await res.json();
|
|
13
|
+
/**
|
|
14
|
+
* Backend response shapes:
|
|
15
|
+
* 1. Not authenticated:
|
|
16
|
+
* { success: false, authenticated: false }
|
|
17
|
+
*
|
|
18
|
+
* 2. Authenticated:
|
|
19
|
+
* {
|
|
20
|
+
* success: true,
|
|
21
|
+
* authenticated: true,
|
|
22
|
+
* data: {
|
|
23
|
+
* userId,
|
|
24
|
+
* accessToken,
|
|
25
|
+
* expiresIn
|
|
26
|
+
* }
|
|
27
|
+
* }
|
|
28
|
+
*/
|
|
29
|
+
if (!json.authenticated || !json.data?.accessToken) {
|
|
30
|
+
return { authenticated: false };
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
authenticated: true,
|
|
34
|
+
accessToken: json.data.accessToken,
|
|
35
|
+
expiresAt: Date.now() + json.data.expiresIn * 1000,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// import { AutherrSessionResponse } from "../types/auth";
|
|
39
|
+
// export async function fetchSession(
|
|
40
|
+
// baseUrl: string,
|
|
41
|
+
// clientId: string
|
|
42
|
+
// ): Promise<AutherrSessionResponse> {
|
|
43
|
+
// const res = await fetch(
|
|
44
|
+
// `${baseUrl}/__bff/auth/session?client_id=${clientId}`,
|
|
45
|
+
// {
|
|
46
|
+
// method: "GET",
|
|
47
|
+
// credentials: "include", // CRITICAL
|
|
48
|
+
// headers: {
|
|
49
|
+
// "Content-Type": "application/json",
|
|
50
|
+
// },
|
|
51
|
+
// }
|
|
52
|
+
// );
|
|
53
|
+
// if (!res.ok) {
|
|
54
|
+
// return { authenticated: false };
|
|
55
|
+
// }
|
|
56
|
+
// const data = (await res.json()) as AutherrSessionResponse;
|
|
57
|
+
// if (!data || !data.accessToken) {
|
|
58
|
+
// return { authenticated: false };
|
|
59
|
+
// }
|
|
60
|
+
// return {
|
|
61
|
+
// authenticated: true,
|
|
62
|
+
// user: data.user,
|
|
63
|
+
// accessToken: data.accessToken,
|
|
64
|
+
// expiresAt: data.expiresAt,
|
|
65
|
+
// };
|
|
66
|
+
// }
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useAutherr } from "../hooks/useAutherr";
|
|
3
|
+
export function LoginButton({ children = "Sign in", className, }) {
|
|
4
|
+
const { login, isAuthenticated } = useAutherr();
|
|
5
|
+
if (isAuthenticated)
|
|
6
|
+
return null;
|
|
7
|
+
return (_jsx("button", { type: "button", onClick: login, className: className, children: children }));
|
|
8
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useAutherr } from "../hooks/useAutherr";
|
|
3
|
+
export function SignupButton({ children = "Sign up", className, }) {
|
|
4
|
+
const { signup, isAuthenticated } = useAutherr();
|
|
5
|
+
if (isAuthenticated)
|
|
6
|
+
return null;
|
|
7
|
+
return (_jsx("button", { type: "button", onClick: signup, className: className, children: children }));
|
|
8
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function UserMenu(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useAutherr } from "../hooks/useAutherr";
|
|
4
|
+
// simple local decoder (no verification)
|
|
5
|
+
function decodeJwt(token) {
|
|
6
|
+
try {
|
|
7
|
+
const [, payload] = token.split(".");
|
|
8
|
+
return JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function UserMenu() {
|
|
15
|
+
const { isAuthenticated, accessToken, login, signup, logout, } = useAutherr();
|
|
16
|
+
const [open, setOpen] = useState(false);
|
|
17
|
+
const [showPayload, setShowPayload] = useState(false);
|
|
18
|
+
const ref = useRef(null);
|
|
19
|
+
// close on outside click
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
function handleClick(e) {
|
|
22
|
+
if (ref.current && !ref.current.contains(e.target)) {
|
|
23
|
+
setOpen(false);
|
|
24
|
+
setShowPayload(false);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
document.addEventListener("mousedown", handleClick);
|
|
28
|
+
return () => document.removeEventListener("mousedown", handleClick);
|
|
29
|
+
}, []);
|
|
30
|
+
const payload = accessToken ? decodeJwt(accessToken) : null;
|
|
31
|
+
const initial = payload?.email?.[0]?.toUpperCase() ?? "U";
|
|
32
|
+
return (_jsxs("div", { ref: ref, style: { position: "relative" }, children: [_jsx("button", { onClick: () => setOpen((v) => !v), style: {
|
|
33
|
+
width: 36,
|
|
34
|
+
height: 36,
|
|
35
|
+
borderRadius: "50%",
|
|
36
|
+
background: "#ff6a00",
|
|
37
|
+
color: "#fff",
|
|
38
|
+
border: "none",
|
|
39
|
+
cursor: "pointer",
|
|
40
|
+
fontWeight: 600,
|
|
41
|
+
}, children: isAuthenticated ? initial : "?" }), open && (_jsx("div", { style: {
|
|
42
|
+
position: "absolute",
|
|
43
|
+
top: "calc(100% + 8px)",
|
|
44
|
+
right: 0,
|
|
45
|
+
width: 280,
|
|
46
|
+
background: "#fff",
|
|
47
|
+
borderRadius: 12,
|
|
48
|
+
boxShadow: "0 10px 30px rgba(0,0,0,0.15)",
|
|
49
|
+
padding: 12,
|
|
50
|
+
zIndex: 1000,
|
|
51
|
+
}, children: isAuthenticated ? (_jsxs(_Fragment, { children: [_jsxs("div", { style: { marginBottom: 12 }, children: [_jsx("div", { style: { fontWeight: 600 }, children: payload?.email }), _jsx("div", { style: { fontSize: 12, color: "#666" }, children: "Signed in" })] }), _jsx(Divider, {}), _jsx(MenuItem, { onClick: () => setShowPayload((v) => !v), children: showPayload ? "Hide payload" : "View payload" }), _jsx(MenuItem, { onClick: logout, children: "Sign out" }), showPayload && payload && (_jsx("pre", { style: {
|
|
52
|
+
marginTop: 10,
|
|
53
|
+
background: "#0b0b0b",
|
|
54
|
+
color: "#00ff88",
|
|
55
|
+
padding: 10,
|
|
56
|
+
borderRadius: 8,
|
|
57
|
+
fontSize: 12,
|
|
58
|
+
maxHeight: 200,
|
|
59
|
+
overflow: "auto",
|
|
60
|
+
}, children: JSON.stringify(payload, null, 2) }))] })) : (_jsxs(_Fragment, { children: [_jsx(MenuItem, { onClick: login, children: "Sign in" }), _jsx(MenuItem, { onClick: signup, children: "Sign up" })] })) }))] }));
|
|
61
|
+
}
|
|
62
|
+
function MenuItem({ children, onClick, }) {
|
|
63
|
+
return (_jsx("button", { onClick: onClick, style: {
|
|
64
|
+
width: "100%",
|
|
65
|
+
padding: "8px 0",
|
|
66
|
+
background: "none",
|
|
67
|
+
border: "none",
|
|
68
|
+
textAlign: "left",
|
|
69
|
+
cursor: "pointer",
|
|
70
|
+
fontSize: 14,
|
|
71
|
+
}, children: children }));
|
|
72
|
+
}
|
|
73
|
+
function Divider() {
|
|
74
|
+
return (_jsx("div", { style: {
|
|
75
|
+
height: 1,
|
|
76
|
+
background: "#eee",
|
|
77
|
+
margin: "8px 0",
|
|
78
|
+
} }));
|
|
79
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare function useAutherr(): {
|
|
2
|
+
user: import("../types/auth").AutherrUser | null;
|
|
3
|
+
accessToken: string | null;
|
|
4
|
+
isAuthenticated: boolean;
|
|
5
|
+
login: () => void;
|
|
6
|
+
signup: () => void;
|
|
7
|
+
logout: () => Promise<void>;
|
|
8
|
+
refreshSession: () => Promise<void>;
|
|
9
|
+
getAccessToken: () => string | null;
|
|
10
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useAutherrContext } from "../provider/AutherrProvider";
|
|
2
|
+
export function useAutherr() {
|
|
3
|
+
const { user, accessToken, isAuthenticated, login, signup, logout, getAccessToken, refreshSession, } = useAutherrContext();
|
|
4
|
+
return {
|
|
5
|
+
user,
|
|
6
|
+
accessToken,
|
|
7
|
+
isAuthenticated,
|
|
8
|
+
login,
|
|
9
|
+
signup,
|
|
10
|
+
logout,
|
|
11
|
+
refreshSession,
|
|
12
|
+
getAccessToken,
|
|
13
|
+
};
|
|
14
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { AutherrProvider } from "./provider/AutherrProvider";
|
|
2
|
+
export { useAutherr } from "./hooks/useAutherr";
|
|
3
|
+
export { LoginButton } from "./components/LoginButton";
|
|
4
|
+
export { SignupButton } from "./components/SignupButton";
|
|
5
|
+
export { UserMenu } from "./components/UserMenu";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { AutherrProvider } from "./provider/AutherrProvider";
|
|
2
|
+
export { useAutherr } from "./hooks/useAutherr";
|
|
3
|
+
export { LoginButton } from "./components/LoginButton";
|
|
4
|
+
export { SignupButton } from "./components/SignupButton";
|
|
5
|
+
export { UserMenu } from "./components/UserMenu";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AutherrUser } from "../types/auth";
|
|
3
|
+
interface AutherrContextValue {
|
|
4
|
+
user: AutherrUser | null;
|
|
5
|
+
accessToken: string | null;
|
|
6
|
+
isAuthenticated: boolean;
|
|
7
|
+
login: () => void;
|
|
8
|
+
signup: () => void;
|
|
9
|
+
logout: () => Promise<void>;
|
|
10
|
+
getAccessToken: () => string | null;
|
|
11
|
+
refreshSession: () => Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
interface AutherrProviderProps {
|
|
14
|
+
children: React.ReactNode;
|
|
15
|
+
clientId: string;
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function AutherrProvider({ children, clientId, baseUrl, }: AutherrProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
19
|
+
export declare function useAutherrContext(): AutherrContextValue;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useEffect, useMemo, useState, } from "react";
|
|
3
|
+
import { fetchSession } from "../api/session";
|
|
4
|
+
import { logoutSession } from "../api/logout";
|
|
5
|
+
const AutherrContext = createContext(null);
|
|
6
|
+
export function AutherrProvider({ children, clientId, baseUrl, }) {
|
|
7
|
+
const [user, setUser] = useState(null);
|
|
8
|
+
const [accessToken, setAccessToken] = useState(null);
|
|
9
|
+
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
10
|
+
const refreshSession = async () => {
|
|
11
|
+
const session = await fetchSession(baseUrl, clientId);
|
|
12
|
+
if (session.authenticated && session.accessToken) {
|
|
13
|
+
setAccessToken(session.accessToken);
|
|
14
|
+
setIsAuthenticated(true);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
setUser(null);
|
|
18
|
+
setAccessToken(null);
|
|
19
|
+
setIsAuthenticated(false);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
refreshSession();
|
|
24
|
+
}, [baseUrl, clientId]);
|
|
25
|
+
const login = () => {
|
|
26
|
+
const state = crypto.randomUUID();
|
|
27
|
+
window.location.href =
|
|
28
|
+
`${baseUrl}/auth/login` +
|
|
29
|
+
`?client_id=${clientId}` +
|
|
30
|
+
`&redirect_uri=${encodeURIComponent(window.location.origin)}` +
|
|
31
|
+
`&state=${state}`;
|
|
32
|
+
};
|
|
33
|
+
const signup = () => {
|
|
34
|
+
const state = crypto.randomUUID();
|
|
35
|
+
window.location.href =
|
|
36
|
+
`${baseUrl}/auth/signup` +
|
|
37
|
+
`?client_id=${clientId}` +
|
|
38
|
+
`&redirect_uri=${encodeURIComponent(window.location.origin)}` +
|
|
39
|
+
`&state=${state}`;
|
|
40
|
+
};
|
|
41
|
+
const logout = async () => {
|
|
42
|
+
try {
|
|
43
|
+
await logoutSession(baseUrl, clientId);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
setUser(null);
|
|
47
|
+
setAccessToken(null);
|
|
48
|
+
setIsAuthenticated(false);
|
|
49
|
+
window.location.href = window.location.origin;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const value = useMemo(() => ({
|
|
53
|
+
user,
|
|
54
|
+
accessToken,
|
|
55
|
+
isAuthenticated,
|
|
56
|
+
login,
|
|
57
|
+
signup,
|
|
58
|
+
logout,
|
|
59
|
+
refreshSession,
|
|
60
|
+
getAccessToken: () => accessToken,
|
|
61
|
+
}), [user, accessToken, isAuthenticated]);
|
|
62
|
+
return (_jsx(AutherrContext.Provider, { value: value, children: children }));
|
|
63
|
+
}
|
|
64
|
+
export function useAutherrContext() {
|
|
65
|
+
const ctx = useContext(AutherrContext);
|
|
66
|
+
if (!ctx) {
|
|
67
|
+
throw new Error("useAutherrContext must be used inside AutherrProvider");
|
|
68
|
+
}
|
|
69
|
+
return ctx;
|
|
70
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface AutherrUser {
|
|
2
|
+
sub: string;
|
|
3
|
+
email: string;
|
|
4
|
+
userType: "platform" | "application";
|
|
5
|
+
applicationId?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface AutherrSessionResponse {
|
|
8
|
+
authenticated: boolean;
|
|
9
|
+
user?: AutherrUser;
|
|
10
|
+
accessToken?: string;
|
|
11
|
+
expiresAt?: number;
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "autherr",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"dest": "dist",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc -p tsconfig.json"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"react": "^19.2.3"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@types/react": "^19.2.8",
|
|
14
|
+
"@types/react-dom": "^19.2.3",
|
|
15
|
+
"typescript": "^5.9.3"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export async function logoutSession(
|
|
2
|
+
baseUrl: string,
|
|
3
|
+
clientId: string
|
|
4
|
+
): Promise<void> {
|
|
5
|
+
await fetch(
|
|
6
|
+
`${baseUrl}/__bff/auth/logout?client_id=${clientId}`,
|
|
7
|
+
{
|
|
8
|
+
method: "POST",
|
|
9
|
+
credentials: "include", // REQUIRED to clear cookie
|
|
10
|
+
}
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { AutherrSessionResponse } from "../types/auth";
|
|
2
|
+
|
|
3
|
+
export async function fetchSession(
|
|
4
|
+
baseUrl: string,
|
|
5
|
+
clientId: string
|
|
6
|
+
): Promise<AutherrSessionResponse> {
|
|
7
|
+
const res = await fetch(
|
|
8
|
+
`${baseUrl}/__bff/auth/session?client_id=${clientId}`,
|
|
9
|
+
{
|
|
10
|
+
method: "GET",
|
|
11
|
+
credentials: "include", // REQUIRED for refresh cookie
|
|
12
|
+
headers: {
|
|
13
|
+
"Content-Type": "application/json",
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
if (!res.ok) {
|
|
19
|
+
return { authenticated: false };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const json = await res.json();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Backend response shapes:
|
|
26
|
+
* 1. Not authenticated:
|
|
27
|
+
* { success: false, authenticated: false }
|
|
28
|
+
*
|
|
29
|
+
* 2. Authenticated:
|
|
30
|
+
* {
|
|
31
|
+
* success: true,
|
|
32
|
+
* authenticated: true,
|
|
33
|
+
* data: {
|
|
34
|
+
* userId,
|
|
35
|
+
* accessToken,
|
|
36
|
+
* expiresIn
|
|
37
|
+
* }
|
|
38
|
+
* }
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
if (!json.authenticated || !json.data?.accessToken) {
|
|
42
|
+
return { authenticated: false };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
authenticated: true,
|
|
47
|
+
accessToken: json.data.accessToken,
|
|
48
|
+
expiresAt: Date.now() + json.data.expiresIn * 1000,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
// import { AutherrSessionResponse } from "../types/auth";
|
|
59
|
+
|
|
60
|
+
// export async function fetchSession(
|
|
61
|
+
// baseUrl: string,
|
|
62
|
+
// clientId: string
|
|
63
|
+
// ): Promise<AutherrSessionResponse> {
|
|
64
|
+
// const res = await fetch(
|
|
65
|
+
// `${baseUrl}/__bff/auth/session?client_id=${clientId}`,
|
|
66
|
+
// {
|
|
67
|
+
// method: "GET",
|
|
68
|
+
// credentials: "include", // CRITICAL
|
|
69
|
+
// headers: {
|
|
70
|
+
// "Content-Type": "application/json",
|
|
71
|
+
// },
|
|
72
|
+
// }
|
|
73
|
+
// );
|
|
74
|
+
|
|
75
|
+
// if (!res.ok) {
|
|
76
|
+
// return { authenticated: false };
|
|
77
|
+
// }
|
|
78
|
+
|
|
79
|
+
// const data = (await res.json()) as AutherrSessionResponse;
|
|
80
|
+
|
|
81
|
+
// if (!data || !data.accessToken) {
|
|
82
|
+
// return { authenticated: false };
|
|
83
|
+
// }
|
|
84
|
+
|
|
85
|
+
// return {
|
|
86
|
+
// authenticated: true,
|
|
87
|
+
// user: data.user,
|
|
88
|
+
// accessToken: data.accessToken,
|
|
89
|
+
// expiresAt: data.expiresAt,
|
|
90
|
+
// };
|
|
91
|
+
// }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useAutherr } from "../hooks/useAutherr";
|
|
3
|
+
|
|
4
|
+
interface LoginButtonProps {
|
|
5
|
+
children?: React.ReactNode;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function LoginButton({
|
|
10
|
+
children = "Sign in",
|
|
11
|
+
className,
|
|
12
|
+
}: LoginButtonProps) {
|
|
13
|
+
const { login, isAuthenticated } = useAutherr();
|
|
14
|
+
|
|
15
|
+
if (isAuthenticated) return null;
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
onClick={login}
|
|
21
|
+
className={className}
|
|
22
|
+
>
|
|
23
|
+
{children}
|
|
24
|
+
</button>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useAutherr } from "../hooks/useAutherr";
|
|
3
|
+
|
|
4
|
+
interface SignupButtonProps {
|
|
5
|
+
children?: React.ReactNode;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function SignupButton({
|
|
10
|
+
children = "Sign up",
|
|
11
|
+
className,
|
|
12
|
+
}: SignupButtonProps) {
|
|
13
|
+
const { signup, isAuthenticated } = useAutherr();
|
|
14
|
+
|
|
15
|
+
if (isAuthenticated) return null;
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
onClick={signup}
|
|
21
|
+
className={className}
|
|
22
|
+
>
|
|
23
|
+
{children}
|
|
24
|
+
</button>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useAutherr } from "../hooks/useAutherr";
|
|
3
|
+
|
|
4
|
+
// simple local decoder (no verification)
|
|
5
|
+
function decodeJwt(token: string) {
|
|
6
|
+
try {
|
|
7
|
+
const [, payload] = token.split(".");
|
|
8
|
+
return JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function UserMenu() {
|
|
15
|
+
const {
|
|
16
|
+
isAuthenticated,
|
|
17
|
+
accessToken,
|
|
18
|
+
login,
|
|
19
|
+
signup,
|
|
20
|
+
logout,
|
|
21
|
+
} = useAutherr();
|
|
22
|
+
|
|
23
|
+
const [open, setOpen] = useState(false);
|
|
24
|
+
const [showPayload, setShowPayload] = useState(false);
|
|
25
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
26
|
+
|
|
27
|
+
// close on outside click
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
function handleClick(e: MouseEvent) {
|
|
30
|
+
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
31
|
+
setOpen(false);
|
|
32
|
+
setShowPayload(false);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
document.addEventListener("mousedown", handleClick);
|
|
36
|
+
return () => document.removeEventListener("mousedown", handleClick);
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
const payload = accessToken ? decodeJwt(accessToken) : null;
|
|
40
|
+
const initial = payload?.email?.[0]?.toUpperCase() ?? "U";
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div ref={ref} style={{ position: "relative" }}>
|
|
44
|
+
{/* Avatar Button */}
|
|
45
|
+
<button
|
|
46
|
+
onClick={() => setOpen((v) => !v)}
|
|
47
|
+
style={{
|
|
48
|
+
width: 36,
|
|
49
|
+
height: 36,
|
|
50
|
+
borderRadius: "50%",
|
|
51
|
+
background: "#ff6a00",
|
|
52
|
+
color: "#fff",
|
|
53
|
+
border: "none",
|
|
54
|
+
cursor: "pointer",
|
|
55
|
+
fontWeight: 600,
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
{isAuthenticated ? initial : "?"}
|
|
59
|
+
</button>
|
|
60
|
+
|
|
61
|
+
{/* Dropdown */}
|
|
62
|
+
{open && (
|
|
63
|
+
<div
|
|
64
|
+
style={{
|
|
65
|
+
position: "absolute",
|
|
66
|
+
top: "calc(100% + 8px)",
|
|
67
|
+
right: 0,
|
|
68
|
+
width: 280,
|
|
69
|
+
background: "#fff",
|
|
70
|
+
borderRadius: 12,
|
|
71
|
+
boxShadow: "0 10px 30px rgba(0,0,0,0.15)",
|
|
72
|
+
padding: 12,
|
|
73
|
+
zIndex: 1000,
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
{isAuthenticated ? (
|
|
77
|
+
<>
|
|
78
|
+
{/* Header */}
|
|
79
|
+
<div style={{ marginBottom: 12 }}>
|
|
80
|
+
<div style={{ fontWeight: 600 }}>{payload?.email}</div>
|
|
81
|
+
<div style={{ fontSize: 12, color: "#666" }}>
|
|
82
|
+
Signed in
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<Divider />
|
|
87
|
+
|
|
88
|
+
<MenuItem onClick={() => setShowPayload((v) => !v)}>
|
|
89
|
+
{showPayload ? "Hide payload" : "View payload"}
|
|
90
|
+
</MenuItem>
|
|
91
|
+
|
|
92
|
+
<MenuItem onClick={logout}>Sign out</MenuItem>
|
|
93
|
+
|
|
94
|
+
{showPayload && payload && (
|
|
95
|
+
<pre
|
|
96
|
+
style={{
|
|
97
|
+
marginTop: 10,
|
|
98
|
+
background: "#0b0b0b",
|
|
99
|
+
color: "#00ff88",
|
|
100
|
+
padding: 10,
|
|
101
|
+
borderRadius: 8,
|
|
102
|
+
fontSize: 12,
|
|
103
|
+
maxHeight: 200,
|
|
104
|
+
overflow: "auto",
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{JSON.stringify(payload, null, 2)}
|
|
108
|
+
</pre>
|
|
109
|
+
)}
|
|
110
|
+
</>
|
|
111
|
+
) : (
|
|
112
|
+
<>
|
|
113
|
+
<MenuItem onClick={login}>Sign in</MenuItem>
|
|
114
|
+
<MenuItem onClick={signup}>Sign up</MenuItem>
|
|
115
|
+
</>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function MenuItem({
|
|
124
|
+
children,
|
|
125
|
+
onClick,
|
|
126
|
+
}: {
|
|
127
|
+
children: React.ReactNode;
|
|
128
|
+
onClick: () => void;
|
|
129
|
+
}) {
|
|
130
|
+
return (
|
|
131
|
+
<button
|
|
132
|
+
onClick={onClick}
|
|
133
|
+
style={{
|
|
134
|
+
width: "100%",
|
|
135
|
+
padding: "8px 0",
|
|
136
|
+
background: "none",
|
|
137
|
+
border: "none",
|
|
138
|
+
textAlign: "left",
|
|
139
|
+
cursor: "pointer",
|
|
140
|
+
fontSize: 14,
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
{children}
|
|
144
|
+
</button>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function Divider() {
|
|
149
|
+
return (
|
|
150
|
+
<div
|
|
151
|
+
style={{
|
|
152
|
+
height: 1,
|
|
153
|
+
background: "#eee",
|
|
154
|
+
margin: "8px 0",
|
|
155
|
+
}}
|
|
156
|
+
/>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useAutherrContext } from "../provider/AutherrProvider";
|
|
2
|
+
|
|
3
|
+
export function useAutherr() {
|
|
4
|
+
const {
|
|
5
|
+
user,
|
|
6
|
+
accessToken,
|
|
7
|
+
isAuthenticated,
|
|
8
|
+
login,
|
|
9
|
+
signup,
|
|
10
|
+
logout,
|
|
11
|
+
getAccessToken,
|
|
12
|
+
refreshSession,
|
|
13
|
+
} = useAutherrContext();
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
user,
|
|
17
|
+
accessToken,
|
|
18
|
+
isAuthenticated,
|
|
19
|
+
login,
|
|
20
|
+
signup,
|
|
21
|
+
logout,
|
|
22
|
+
refreshSession,
|
|
23
|
+
getAccessToken,
|
|
24
|
+
};
|
|
25
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { AutherrProvider } from "./provider/AutherrProvider";
|
|
2
|
+
export { useAutherr } from "./hooks/useAutherr";
|
|
3
|
+
|
|
4
|
+
export { LoginButton } from "./components/LoginButton";
|
|
5
|
+
export { SignupButton } from "./components/SignupButton";
|
|
6
|
+
export { UserMenu } from "./components/UserMenu";
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useState,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { fetchSession } from "../api/session";
|
|
9
|
+
import { logoutSession } from "../api/logout";
|
|
10
|
+
import { AutherrUser } from "../types/auth";
|
|
11
|
+
|
|
12
|
+
interface AutherrContextValue {
|
|
13
|
+
user: AutherrUser | null;
|
|
14
|
+
accessToken: string | null;
|
|
15
|
+
isAuthenticated: boolean;
|
|
16
|
+
login: () => void;
|
|
17
|
+
signup: () => void;
|
|
18
|
+
logout: () => Promise<void>;
|
|
19
|
+
getAccessToken: () => string | null;
|
|
20
|
+
refreshSession: () => Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const AutherrContext = createContext<AutherrContextValue | null>(null);
|
|
24
|
+
|
|
25
|
+
interface AutherrProviderProps {
|
|
26
|
+
children: React.ReactNode;
|
|
27
|
+
clientId: string;
|
|
28
|
+
baseUrl: string; // e.g. https://autherr.com
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function AutherrProvider({
|
|
32
|
+
children,
|
|
33
|
+
clientId,
|
|
34
|
+
baseUrl,
|
|
35
|
+
}: AutherrProviderProps) {
|
|
36
|
+
const [user, setUser] = useState<AutherrUser | null>(null);
|
|
37
|
+
const [accessToken, setAccessToken] = useState<string | null>(null);
|
|
38
|
+
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
39
|
+
|
|
40
|
+
const refreshSession = async () => {
|
|
41
|
+
const session = await fetchSession(baseUrl, clientId);
|
|
42
|
+
|
|
43
|
+
if (session.authenticated && session.accessToken) {
|
|
44
|
+
setAccessToken(session.accessToken);
|
|
45
|
+
setIsAuthenticated(true);
|
|
46
|
+
} else {
|
|
47
|
+
setUser(null);
|
|
48
|
+
setAccessToken(null);
|
|
49
|
+
setIsAuthenticated(false);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
refreshSession();
|
|
56
|
+
}, [baseUrl, clientId]);
|
|
57
|
+
|
|
58
|
+
const login = () => {
|
|
59
|
+
const state = crypto.randomUUID();
|
|
60
|
+
window.location.href =
|
|
61
|
+
`${baseUrl}/auth/login` +
|
|
62
|
+
`?client_id=${clientId}` +
|
|
63
|
+
`&redirect_uri=${encodeURIComponent(window.location.origin)}` +
|
|
64
|
+
`&state=${state}`;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const signup = () => {
|
|
68
|
+
const state = crypto.randomUUID();
|
|
69
|
+
window.location.href =
|
|
70
|
+
`${baseUrl}/auth/signup` +
|
|
71
|
+
`?client_id=${clientId}` +
|
|
72
|
+
`&redirect_uri=${encodeURIComponent(window.location.origin)}` +
|
|
73
|
+
`&state=${state}`;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const logout = async () => {
|
|
77
|
+
try {
|
|
78
|
+
await logoutSession(baseUrl, clientId);
|
|
79
|
+
} finally {
|
|
80
|
+
setUser(null);
|
|
81
|
+
setAccessToken(null);
|
|
82
|
+
setIsAuthenticated(false);
|
|
83
|
+
window.location.href = window.location.origin;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const value = useMemo(
|
|
88
|
+
() => ({
|
|
89
|
+
user,
|
|
90
|
+
accessToken,
|
|
91
|
+
isAuthenticated,
|
|
92
|
+
login,
|
|
93
|
+
signup,
|
|
94
|
+
logout,
|
|
95
|
+
refreshSession,
|
|
96
|
+
getAccessToken: () => accessToken,
|
|
97
|
+
}),
|
|
98
|
+
[user, accessToken, isAuthenticated]
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<AutherrContext.Provider value={value}>
|
|
103
|
+
{children}
|
|
104
|
+
</AutherrContext.Provider>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function useAutherrContext() {
|
|
109
|
+
const ctx = useContext(AutherrContext);
|
|
110
|
+
if (!ctx) {
|
|
111
|
+
throw new Error("useAutherrContext must be used inside AutherrProvider");
|
|
112
|
+
}
|
|
113
|
+
return ctx;
|
|
114
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface AutherrUser {
|
|
2
|
+
sub: string;
|
|
3
|
+
email: string;
|
|
4
|
+
userType: "platform" | "application";
|
|
5
|
+
applicationId?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface AutherrSessionResponse {
|
|
9
|
+
authenticated: boolean;
|
|
10
|
+
user?: AutherrUser;
|
|
11
|
+
accessToken?: string;
|
|
12
|
+
expiresAt?: number;
|
|
13
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Node",
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"outDir": "dist",
|
|
9
|
+
"rootDir": "src",
|
|
10
|
+
"strict": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"forceConsistentCasingInFileNames": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src"],
|
|
16
|
+
"exclude": ["node_modules", "dist"]
|
|
17
|
+
}
|