@sanvika/auth 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/SanvikaAccountButton.jsx +295 -0
- package/SanvikaAccountButton.module.css +418 -0
- package/SanvikaAuthProvider.jsx +171 -0
- package/constants.js +32 -0
- package/index.js +16 -0
- package/package.json +31 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// ============================================================
|
|
3
|
+
// SanvikaAccountButton — Drop-in navbar button for all Sanvika apps
|
|
4
|
+
//
|
|
5
|
+
// States:
|
|
6
|
+
// loading → skeleton placeholder (no flash)
|
|
7
|
+
// guest → "Account" button → redirects to Sanvika SSO
|
|
8
|
+
// logged-in → user image + firstName → dropdown (Dashboard + Logout)
|
|
9
|
+
//
|
|
10
|
+
// Requirements:
|
|
11
|
+
// 1. Wrap your root layout with <SanvikaAuthProvider ...>
|
|
12
|
+
// 2. Drop <SanvikaAccountButton /> anywhere in your navbar
|
|
13
|
+
// No other setup needed.
|
|
14
|
+
//
|
|
15
|
+
// Props:
|
|
16
|
+
// text — button label when guest (default: "Account")
|
|
17
|
+
// hideTextOnMobile — hides text on small screens (default: false)
|
|
18
|
+
// onLoginClick — optional override for login click (default: redirectToSSO)
|
|
19
|
+
// onProfileClick — optional override for profile click (default: dashboardPath)
|
|
20
|
+
// onboardingPath — where to redirect if user.status === "onboarding" (default: null)
|
|
21
|
+
// className — extra CSS class for outer wrapper
|
|
22
|
+
// ============================================================
|
|
23
|
+
|
|
24
|
+
import { useEffect, useRef, useState } from "react";
|
|
25
|
+
import { useSanvikaAuth } from "./SanvikaAuthProvider.jsx";
|
|
26
|
+
import { DEFAULT_AVATAR_SVG } from "./constants.js";
|
|
27
|
+
import styles from "./SanvikaAccountButton.module.css";
|
|
28
|
+
|
|
29
|
+
// ─── SVG Icons ─────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function UserIcon({ size = 18 }) {
|
|
32
|
+
return (
|
|
33
|
+
<svg
|
|
34
|
+
width={size}
|
|
35
|
+
height={size}
|
|
36
|
+
viewBox="0 0 24 24"
|
|
37
|
+
fill="none"
|
|
38
|
+
stroke="currentColor"
|
|
39
|
+
strokeWidth="2"
|
|
40
|
+
strokeLinecap="round"
|
|
41
|
+
strokeLinejoin="round"
|
|
42
|
+
>
|
|
43
|
+
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
|
44
|
+
<circle cx="12" cy="7" r="4" />
|
|
45
|
+
</svg>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ChevronIcon({ open }) {
|
|
50
|
+
return (
|
|
51
|
+
<svg
|
|
52
|
+
width="12"
|
|
53
|
+
height="12"
|
|
54
|
+
viewBox="0 0 24 24"
|
|
55
|
+
fill="none"
|
|
56
|
+
stroke="currentColor"
|
|
57
|
+
strokeWidth="2.5"
|
|
58
|
+
strokeLinecap="round"
|
|
59
|
+
style={{
|
|
60
|
+
transform: open ? "rotate(180deg)" : "rotate(0deg)",
|
|
61
|
+
transition: "transform 0.2s",
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
<polyline points="6 9 12 15 18 9" />
|
|
65
|
+
</svg>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function DashboardIcon() {
|
|
70
|
+
return (
|
|
71
|
+
<svg
|
|
72
|
+
width="15"
|
|
73
|
+
height="15"
|
|
74
|
+
viewBox="0 0 24 24"
|
|
75
|
+
fill="none"
|
|
76
|
+
stroke="currentColor"
|
|
77
|
+
strokeWidth="2"
|
|
78
|
+
strokeLinecap="round"
|
|
79
|
+
>
|
|
80
|
+
<rect x="3" y="3" width="7" height="7" />
|
|
81
|
+
<rect x="14" y="3" width="7" height="7" />
|
|
82
|
+
<rect x="3" y="14" width="7" height="7" />
|
|
83
|
+
<rect x="14" y="14" width="7" height="7" />
|
|
84
|
+
</svg>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function LogoutIcon() {
|
|
89
|
+
return (
|
|
90
|
+
<svg
|
|
91
|
+
width="15"
|
|
92
|
+
height="15"
|
|
93
|
+
viewBox="0 0 24 24"
|
|
94
|
+
fill="none"
|
|
95
|
+
stroke="currentColor"
|
|
96
|
+
strokeWidth="2"
|
|
97
|
+
strokeLinecap="round"
|
|
98
|
+
>
|
|
99
|
+
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
|
100
|
+
<polyline points="16 17 21 12 16 7" />
|
|
101
|
+
<line x1="21" y1="12" x2="9" y2="12" />
|
|
102
|
+
</svg>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Main Component ────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
export default function SanvikaAccountButton({
|
|
109
|
+
text = "Account",
|
|
110
|
+
hideTextOnMobile = false,
|
|
111
|
+
onLoginClick,
|
|
112
|
+
onProfileClick,
|
|
113
|
+
onboardingPath = null,
|
|
114
|
+
className = "",
|
|
115
|
+
}) {
|
|
116
|
+
const {
|
|
117
|
+
user,
|
|
118
|
+
loading,
|
|
119
|
+
isLoggedIn,
|
|
120
|
+
logout,
|
|
121
|
+
redirectToLogin,
|
|
122
|
+
dashboardPath,
|
|
123
|
+
updateUser,
|
|
124
|
+
} = useSanvikaAuth();
|
|
125
|
+
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
126
|
+
const [imgError, setImgError] = useState(false);
|
|
127
|
+
const [prevImage, setPrevImage] = useState(user?.image);
|
|
128
|
+
const wrapperRef = useRef(null);
|
|
129
|
+
|
|
130
|
+
// Derived state — reset imgError when image changes
|
|
131
|
+
if (user?.image !== prevImage) {
|
|
132
|
+
setPrevImage(user?.image);
|
|
133
|
+
setImgError(false);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Listen for profileUpdated event → refresh user in context
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (!user) return;
|
|
139
|
+
let disposed = false;
|
|
140
|
+
const handleProfileUpdated = async () => {
|
|
141
|
+
if (disposed) return;
|
|
142
|
+
try {
|
|
143
|
+
const stored = localStorage.getItem("sanvika_user");
|
|
144
|
+
if (stored) updateUser(JSON.parse(stored));
|
|
145
|
+
} catch {
|
|
146
|
+
/* ignore */
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
window.addEventListener("profileUpdated", handleProfileUpdated);
|
|
150
|
+
return () => {
|
|
151
|
+
disposed = true;
|
|
152
|
+
window.removeEventListener("profileUpdated", handleProfileUpdated);
|
|
153
|
+
};
|
|
154
|
+
}, [user, updateUser]);
|
|
155
|
+
|
|
156
|
+
// Close dropdown on outside click
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!dropdownOpen) return;
|
|
159
|
+
function handleOutside(e) {
|
|
160
|
+
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
|
|
161
|
+
setDropdownOpen(false);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
document.addEventListener("mousedown", handleOutside);
|
|
165
|
+
return () => document.removeEventListener("mousedown", handleOutside);
|
|
166
|
+
}, [dropdownOpen]);
|
|
167
|
+
|
|
168
|
+
// ── Loading skeleton ─────────────────────────────────────────────────
|
|
169
|
+
if (loading) {
|
|
170
|
+
return <div className={styles.skeleton} aria-hidden="true" />;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Guest state ──────────────────────────────────────────────────────
|
|
174
|
+
if (!isLoggedIn) {
|
|
175
|
+
return (
|
|
176
|
+
<button
|
|
177
|
+
className={`${styles.guestBtn} ${className}`}
|
|
178
|
+
onClick={() => {
|
|
179
|
+
if (onLoginClick) {
|
|
180
|
+
onLoginClick();
|
|
181
|
+
} else {
|
|
182
|
+
redirectToLogin(window.location.pathname);
|
|
183
|
+
}
|
|
184
|
+
}}
|
|
185
|
+
aria-label="Login or Sign Up"
|
|
186
|
+
>
|
|
187
|
+
<span className={styles.iconWrap}>
|
|
188
|
+
<UserIcon size={18} />
|
|
189
|
+
</span>
|
|
190
|
+
<span
|
|
191
|
+
className={
|
|
192
|
+
hideTextOnMobile ? styles.hideTextOnMobile : styles.textWrap
|
|
193
|
+
}
|
|
194
|
+
>
|
|
195
|
+
{text}
|
|
196
|
+
</span>
|
|
197
|
+
</button>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Logged-in state ──────────────────────────────────────────────────
|
|
202
|
+
const displayName = user.firstName || user.mobile?.slice(-4) || "Me";
|
|
203
|
+
const imageSrc = !imgError && user.image ? user.image : DEFAULT_AVATAR_SVG;
|
|
204
|
+
|
|
205
|
+
// Profile click — onboarding check
|
|
206
|
+
const handleProfileClick = () => {
|
|
207
|
+
if (onProfileClick) return onProfileClick();
|
|
208
|
+
if (onboardingPath && user.status === "onboarding") {
|
|
209
|
+
window.location.href = onboardingPath;
|
|
210
|
+
} else {
|
|
211
|
+
window.location.href = dashboardPath;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<div ref={wrapperRef} className={`${styles.wrapper} ${className}`}>
|
|
217
|
+
{/* Avatar + Name button */}
|
|
218
|
+
<button
|
|
219
|
+
className={styles.profileBtn}
|
|
220
|
+
onClick={() => setDropdownOpen((o) => !o)}
|
|
221
|
+
onDoubleClick={handleProfileClick}
|
|
222
|
+
aria-label="Account menu"
|
|
223
|
+
aria-expanded={dropdownOpen}
|
|
224
|
+
>
|
|
225
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
226
|
+
<img
|
|
227
|
+
src={imageSrc}
|
|
228
|
+
alt={displayName}
|
|
229
|
+
className={styles.avatar}
|
|
230
|
+
onError={() => setImgError(true)}
|
|
231
|
+
/>
|
|
232
|
+
<span
|
|
233
|
+
className={
|
|
234
|
+
hideTextOnMobile ? styles.hideTextOnMobile : styles.textWrap
|
|
235
|
+
}
|
|
236
|
+
>
|
|
237
|
+
{displayName}
|
|
238
|
+
</span>
|
|
239
|
+
<ChevronIcon open={dropdownOpen} />
|
|
240
|
+
</button>
|
|
241
|
+
|
|
242
|
+
{/* Dropdown */}
|
|
243
|
+
{dropdownOpen && (
|
|
244
|
+
<div className={styles.dropdown} role="menu">
|
|
245
|
+
{/* User info header */}
|
|
246
|
+
<div className={styles.dropdownHeader}>
|
|
247
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
248
|
+
<img
|
|
249
|
+
src={imageSrc}
|
|
250
|
+
alt={displayName}
|
|
251
|
+
className={styles.dropdownAvatar}
|
|
252
|
+
onError={() => setImgError(true)}
|
|
253
|
+
/>
|
|
254
|
+
<div>
|
|
255
|
+
<div className={styles.dropdownName}>
|
|
256
|
+
{[user.firstName, user.lastName].filter(Boolean).join(" ")}
|
|
257
|
+
</div>
|
|
258
|
+
<div className={styles.dropdownMobile}>+91 {user.mobile}</div>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
<div className={styles.divider} />
|
|
262
|
+
|
|
263
|
+
{/* Dashboard link */}
|
|
264
|
+
<a
|
|
265
|
+
href={
|
|
266
|
+
onboardingPath && user.status === "onboarding"
|
|
267
|
+
? onboardingPath
|
|
268
|
+
: dashboardPath
|
|
269
|
+
}
|
|
270
|
+
className={styles.dropdownItem}
|
|
271
|
+
role="menuitem"
|
|
272
|
+
onClick={() => setDropdownOpen(false)}
|
|
273
|
+
>
|
|
274
|
+
<DashboardIcon />
|
|
275
|
+
<span>Dashboard</span>
|
|
276
|
+
</a>
|
|
277
|
+
|
|
278
|
+
{/* Logout */}
|
|
279
|
+
<button
|
|
280
|
+
className={`${styles.dropdownItem} ${styles.logoutItem}`}
|
|
281
|
+
role="menuitem"
|
|
282
|
+
onClick={async () => {
|
|
283
|
+
setDropdownOpen(false);
|
|
284
|
+
await logout();
|
|
285
|
+
window.location.href = "/";
|
|
286
|
+
}}
|
|
287
|
+
>
|
|
288
|
+
<LogoutIcon />
|
|
289
|
+
<span>Logout</span>
|
|
290
|
+
</button>
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
SanvikaAccountButton — Component Styles
|
|
3
|
+
|
|
4
|
+
DESIGN DECISION:
|
|
5
|
+
- Guest button (Login/Account): Sanvika brand colors HARDCODED
|
|
6
|
+
→ Always purple, regardless of host project's theme
|
|
7
|
+
→ CSS var with fallback: var(--sanvika-brand-color, #4f46e5)
|
|
8
|
+
- Profile button + Dropdown: host project theme vars + fallbacks
|
|
9
|
+
→ var(--card-bg, #fff) — adapts if host has theme system
|
|
10
|
+
|
|
11
|
+
clamp() for fluid sizing, 44px touch targets, CSS Modules only.
|
|
12
|
+
============================================================ */
|
|
13
|
+
|
|
14
|
+
/* ── Shimmer keyframe ───────────────────────────────────────── */
|
|
15
|
+
@keyframes shimmer {
|
|
16
|
+
0% {
|
|
17
|
+
background-position: 200% 0;
|
|
18
|
+
}
|
|
19
|
+
100% {
|
|
20
|
+
background-position: -200% 0;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* ── Loading Skeleton ───────────────────────────────────────── */
|
|
25
|
+
.skeleton {
|
|
26
|
+
width: clamp(72px, 20vw, 96px);
|
|
27
|
+
height: clamp(34px, 8vw, 40px);
|
|
28
|
+
border-radius: 8px;
|
|
29
|
+
background: linear-gradient(
|
|
30
|
+
90deg,
|
|
31
|
+
var(--skeleton-base-color, #d0d0d0) 25%,
|
|
32
|
+
var(--skeleton-highlight-color, #f0f0f0) 50%,
|
|
33
|
+
var(--skeleton-base-color, #d0d0d0) 75%
|
|
34
|
+
);
|
|
35
|
+
background-size: 200% 100%;
|
|
36
|
+
animation: shimmer 1.4s infinite;
|
|
37
|
+
display: inline-block;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* ── Wrapper (logged-in state relative anchor) ──────────────── */
|
|
41
|
+
.wrapper {
|
|
42
|
+
position: relative;
|
|
43
|
+
display: inline-block;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* ── Guest Button ───────────────────────────────────────────── */
|
|
47
|
+
/* Sanvika brand identity — always purple, self-contained */
|
|
48
|
+
.guestBtn {
|
|
49
|
+
display: inline-flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
gap: 6px;
|
|
52
|
+
padding: clamp(6px, 2vw, 8px) clamp(10px, 3vw, 16px);
|
|
53
|
+
min-height: 44px; /* Touch target rule */
|
|
54
|
+
min-width: 44px;
|
|
55
|
+
background: var(--sanvika-brand-color, #4f46e5);
|
|
56
|
+
color: #ffffff;
|
|
57
|
+
border: none;
|
|
58
|
+
border-radius: 8px;
|
|
59
|
+
font-size: clamp(13px, 3vw, 14px);
|
|
60
|
+
font-weight: 600;
|
|
61
|
+
cursor: pointer;
|
|
62
|
+
transition:
|
|
63
|
+
background-color 0.3s ease,
|
|
64
|
+
color 0.3s ease;
|
|
65
|
+
white-space: nowrap;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.guestBtn:hover {
|
|
69
|
+
background: var(--sanvika-brand-hover, #4338ca);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.iconWrap {
|
|
73
|
+
display: flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
flex-shrink: 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ── Profile Button (logged-in) ─────────────────────────────── */
|
|
79
|
+
/* Adapts to host project theme via vars + fallbacks */
|
|
80
|
+
.profileBtn {
|
|
81
|
+
display: inline-flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
gap: 7px;
|
|
84
|
+
padding: 5px 10px 5px 5px;
|
|
85
|
+
min-height: 44px; /* Touch target rule */
|
|
86
|
+
background: var(--muted-bg, #f5f5f5);
|
|
87
|
+
border: 1px solid var(--border-color-light, #e5e7eb);
|
|
88
|
+
border-radius: 99px;
|
|
89
|
+
font-size: clamp(13px, 3vw, 14px);
|
|
90
|
+
font-weight: 600;
|
|
91
|
+
cursor: pointer;
|
|
92
|
+
color: var(--text-color, #1a1a1a);
|
|
93
|
+
transition:
|
|
94
|
+
background-color 0.3s ease,
|
|
95
|
+
border-color 0.3s ease,
|
|
96
|
+
color 0.3s ease;
|
|
97
|
+
white-space: nowrap;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.profileBtn:hover {
|
|
101
|
+
background: var(--hover-color, #ebebeb);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ── Avatar (nav button) ────────────────────────────────────── */
|
|
105
|
+
.avatar {
|
|
106
|
+
width: 28px;
|
|
107
|
+
height: 28px;
|
|
108
|
+
border-radius: 50%;
|
|
109
|
+
object-fit: cover;
|
|
110
|
+
border: 2px solid var(--sanvika-brand-color, #4f46e5);
|
|
111
|
+
flex-shrink: 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* ── Text spans ─────────────────────────────────────────────── */
|
|
115
|
+
.textWrap {
|
|
116
|
+
display: inline;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.hideTextOnMobile {
|
|
120
|
+
display: none;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@media (min-width: 500px) {
|
|
124
|
+
.hideTextOnMobile {
|
|
125
|
+
display: inline;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* ── Dropdown ───────────────────────────────────────────────── */
|
|
130
|
+
/* Adapts to host project theme */
|
|
131
|
+
.dropdown {
|
|
132
|
+
position: absolute;
|
|
133
|
+
top: calc(100% + 8px);
|
|
134
|
+
right: 0;
|
|
135
|
+
min-width: clamp(200px, 60vw, 240px);
|
|
136
|
+
background: var(--card-bg, #ffffff);
|
|
137
|
+
border: 1px solid var(--border-color-light, #e5e7eb);
|
|
138
|
+
border-radius: 12px;
|
|
139
|
+
box-shadow: 0 8px 24px var(--shadow-color, rgba(0, 0, 0, 0.12));
|
|
140
|
+
z-index: 9999;
|
|
141
|
+
overflow: hidden;
|
|
142
|
+
transition:
|
|
143
|
+
background-color 0.3s ease,
|
|
144
|
+
border-color 0.3s ease;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.dropdownHeader {
|
|
148
|
+
display: flex;
|
|
149
|
+
align-items: center;
|
|
150
|
+
gap: 10px;
|
|
151
|
+
padding: 14px 16px;
|
|
152
|
+
background: var(--section-bg, #fafafa);
|
|
153
|
+
transition: background-color 0.3s ease;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.dropdownAvatar {
|
|
157
|
+
width: 38px;
|
|
158
|
+
height: 38px;
|
|
159
|
+
border-radius: 50%;
|
|
160
|
+
object-fit: cover;
|
|
161
|
+
border: 2px solid var(--sanvika-brand-color, #4f46e5);
|
|
162
|
+
flex-shrink: 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.dropdownName {
|
|
166
|
+
font-size: clamp(13px, 3vw, 14px);
|
|
167
|
+
font-weight: 700;
|
|
168
|
+
color: var(--text-color, #1a1a1a);
|
|
169
|
+
line-height: 1.3;
|
|
170
|
+
transition: color 0.3s ease;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.dropdownMobile {
|
|
174
|
+
font-size: clamp(11px, 2.5vw, 12px);
|
|
175
|
+
color: var(--secondary-text-color, #888888);
|
|
176
|
+
margin-top: 2px;
|
|
177
|
+
transition: color 0.3s ease;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.divider {
|
|
181
|
+
height: 1px;
|
|
182
|
+
background: var(--muted-border, #f0f0f0);
|
|
183
|
+
transition: background-color 0.3s ease;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* ── Dropdown Items ─────────────────────────────────────────── */
|
|
187
|
+
.dropdownItem {
|
|
188
|
+
display: flex;
|
|
189
|
+
align-items: center;
|
|
190
|
+
gap: 10px;
|
|
191
|
+
width: 100%;
|
|
192
|
+
padding: 11px 16px;
|
|
193
|
+
min-height: 44px; /* Touch target rule */
|
|
194
|
+
background: none;
|
|
195
|
+
border: none;
|
|
196
|
+
font-size: clamp(13px, 3vw, 14px);
|
|
197
|
+
color: var(--text-color, #333333);
|
|
198
|
+
cursor: pointer;
|
|
199
|
+
text-decoration: none;
|
|
200
|
+
text-align: left;
|
|
201
|
+
transition:
|
|
202
|
+
background-color 0.3s ease,
|
|
203
|
+
color 0.3s ease;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.dropdownItem:hover {
|
|
207
|
+
background: var(--hover-color, #f7f7f7);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.logoutItem {
|
|
211
|
+
color: var(--error-color, #c0392b);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.logoutItem:hover {
|
|
215
|
+
background: var(--error-bg, #fff5f5);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* ── Shimmer keyframe ───────────────────────────────────────── */
|
|
219
|
+
@keyframes shimmer {
|
|
220
|
+
0% {
|
|
221
|
+
background-position: 200% 0;
|
|
222
|
+
}
|
|
223
|
+
100% {
|
|
224
|
+
background-position: -200% 0;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/* ── Loading Skeleton ───────────────────────────────────────── */
|
|
229
|
+
.skeleton {
|
|
230
|
+
width: clamp(72px, 20vw, 96px);
|
|
231
|
+
height: clamp(34px, 8vw, 40px);
|
|
232
|
+
border-radius: 8px;
|
|
233
|
+
background: linear-gradient(
|
|
234
|
+
90deg,
|
|
235
|
+
var(--skeleton-base-color) 25%,
|
|
236
|
+
var(--skeleton-highlight-color) 50%,
|
|
237
|
+
var(--skeleton-base-color) 75%
|
|
238
|
+
);
|
|
239
|
+
background-size: 200% 100%;
|
|
240
|
+
animation: shimmer 1.4s infinite;
|
|
241
|
+
display: inline-block;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* ── Wrapper (logged-in state relative anchor) ──────────────── */
|
|
245
|
+
.wrapper {
|
|
246
|
+
position: relative;
|
|
247
|
+
display: inline-block;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* ── Guest Button ───────────────────────────────────────────── */
|
|
251
|
+
.guestBtn {
|
|
252
|
+
display: inline-flex;
|
|
253
|
+
align-items: center;
|
|
254
|
+
gap: 6px;
|
|
255
|
+
padding: clamp(6px, 2vw, 8px) clamp(10px, 3vw, 16px);
|
|
256
|
+
min-height: 44px; /* Touch target rule */
|
|
257
|
+
min-width: 44px;
|
|
258
|
+
background: var(--sanvika-brand-color);
|
|
259
|
+
color: var(--btn-primary-text);
|
|
260
|
+
border: none;
|
|
261
|
+
border-radius: 8px;
|
|
262
|
+
font-size: clamp(13px, 3vw, 14px);
|
|
263
|
+
font-weight: 600;
|
|
264
|
+
cursor: pointer;
|
|
265
|
+
transition:
|
|
266
|
+
background-color 0.3s ease,
|
|
267
|
+
color 0.3s ease;
|
|
268
|
+
white-space: nowrap;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.guestBtn:hover {
|
|
272
|
+
background: var(--sanvika-brand-hover);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.iconWrap {
|
|
276
|
+
display: flex;
|
|
277
|
+
align-items: center;
|
|
278
|
+
flex-shrink: 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* ── Profile Button (logged-in) ─────────────────────────────── */
|
|
282
|
+
.profileBtn {
|
|
283
|
+
display: inline-flex;
|
|
284
|
+
align-items: center;
|
|
285
|
+
gap: 7px;
|
|
286
|
+
padding: 5px 10px 5px 5px;
|
|
287
|
+
min-height: 44px; /* Touch target rule */
|
|
288
|
+
background: var(--muted-bg);
|
|
289
|
+
border: 1px solid var(--border-color-light);
|
|
290
|
+
border-radius: 99px;
|
|
291
|
+
font-size: clamp(13px, 3vw, 14px);
|
|
292
|
+
font-weight: 600;
|
|
293
|
+
cursor: pointer;
|
|
294
|
+
color: var(--text-color);
|
|
295
|
+
transition:
|
|
296
|
+
background-color 0.3s ease,
|
|
297
|
+
border-color 0.3s ease,
|
|
298
|
+
color 0.3s ease;
|
|
299
|
+
white-space: nowrap;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.profileBtn:hover {
|
|
303
|
+
background: var(--hover-color);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* ── Avatar (nav button) ────────────────────────────────────── */
|
|
307
|
+
.avatar {
|
|
308
|
+
width: 28px;
|
|
309
|
+
height: 28px;
|
|
310
|
+
border-radius: 50%;
|
|
311
|
+
object-fit: cover;
|
|
312
|
+
border: 2px solid var(--sanvika-brand-color);
|
|
313
|
+
flex-shrink: 0;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/* ── Text spans ─────────────────────────────────────────────── */
|
|
317
|
+
.textWrap {
|
|
318
|
+
display: inline;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.hideTextOnMobile {
|
|
322
|
+
display: none;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/* clamp-based show — 500px+ par text dikhao */
|
|
326
|
+
@media (min-width: 500px) {
|
|
327
|
+
.hideTextOnMobile {
|
|
328
|
+
display: inline;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/* ── Dropdown ───────────────────────────────────────────────── */
|
|
333
|
+
.dropdown {
|
|
334
|
+
position: absolute;
|
|
335
|
+
top: calc(100% + 8px);
|
|
336
|
+
right: 0;
|
|
337
|
+
min-width: clamp(200px, 60vw, 240px);
|
|
338
|
+
background: var(--card-bg);
|
|
339
|
+
border: 1px solid var(--border-color-light);
|
|
340
|
+
border-radius: 12px;
|
|
341
|
+
box-shadow: 0 8px 24px var(--shadow-color);
|
|
342
|
+
z-index: 9999;
|
|
343
|
+
overflow: hidden;
|
|
344
|
+
transition:
|
|
345
|
+
background-color 0.3s ease,
|
|
346
|
+
border-color 0.3s ease;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
.dropdownHeader {
|
|
350
|
+
display: flex;
|
|
351
|
+
align-items: center;
|
|
352
|
+
gap: 10px;
|
|
353
|
+
padding: 14px 16px;
|
|
354
|
+
background: var(--section-bg);
|
|
355
|
+
transition: background-color 0.3s ease;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.dropdownAvatar {
|
|
359
|
+
width: 38px;
|
|
360
|
+
height: 38px;
|
|
361
|
+
border-radius: 50%;
|
|
362
|
+
object-fit: cover;
|
|
363
|
+
border: 2px solid var(--sanvika-brand-color);
|
|
364
|
+
flex-shrink: 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
.dropdownName {
|
|
368
|
+
font-size: clamp(13px, 3vw, 14px);
|
|
369
|
+
font-weight: 700;
|
|
370
|
+
color: var(--text-color);
|
|
371
|
+
line-height: 1.3;
|
|
372
|
+
transition: color 0.3s ease;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
.dropdownMobile {
|
|
376
|
+
font-size: clamp(11px, 2.5vw, 12px);
|
|
377
|
+
color: var(--secondary-text-color);
|
|
378
|
+
margin-top: 2px;
|
|
379
|
+
transition: color 0.3s ease;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.divider {
|
|
383
|
+
height: 1px;
|
|
384
|
+
background: var(--muted-border);
|
|
385
|
+
transition: background-color 0.3s ease;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/* ── Dropdown Items ─────────────────────────────────────────── */
|
|
389
|
+
.dropdownItem {
|
|
390
|
+
display: flex;
|
|
391
|
+
align-items: center;
|
|
392
|
+
gap: 10px;
|
|
393
|
+
width: 100%;
|
|
394
|
+
padding: 11px 16px;
|
|
395
|
+
min-height: 44px; /* Touch target rule */
|
|
396
|
+
background: none;
|
|
397
|
+
border: none;
|
|
398
|
+
font-size: clamp(13px, 3vw, 14px);
|
|
399
|
+
color: var(--text-color);
|
|
400
|
+
cursor: pointer;
|
|
401
|
+
text-decoration: none;
|
|
402
|
+
text-align: left;
|
|
403
|
+
transition:
|
|
404
|
+
background-color 0.3s ease,
|
|
405
|
+
color 0.3s ease;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.dropdownItem:hover {
|
|
409
|
+
background: var(--hover-color);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.logoutItem {
|
|
413
|
+
color: var(--error-color);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.logoutItem:hover {
|
|
417
|
+
background: var(--error-bg);
|
|
418
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// ============================================================
|
|
3
|
+
// SanvikaAuthProvider — Context + global auth state manager
|
|
4
|
+
// Wrap your root layout with this provider.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// <SanvikaAuthProvider
|
|
8
|
+
// clientId="sanvika_XXXXXXXXXX"
|
|
9
|
+
// redirectUri="https://yourapp.com/auth/callback"
|
|
10
|
+
// iamUrl="https://accounts.sanvikaproduction.com" // optional
|
|
11
|
+
// dashboardPath="/dashboard" // optional
|
|
12
|
+
// >
|
|
13
|
+
// {children}
|
|
14
|
+
// </SanvikaAuthProvider>
|
|
15
|
+
// ============================================================
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
createContext,
|
|
19
|
+
useContext,
|
|
20
|
+
useEffect,
|
|
21
|
+
useState,
|
|
22
|
+
useCallback,
|
|
23
|
+
} from "react";
|
|
24
|
+
import { STORAGE_KEYS, DEFAULT_IAM_URL } from "./constants.js";
|
|
25
|
+
|
|
26
|
+
// ─── Context ──────────────────────────────────────────────────────────────
|
|
27
|
+
export const SanvikaAuthContext = createContext(null);
|
|
28
|
+
|
|
29
|
+
// ─── Provider ─────────────────────────────────────────────────────────────
|
|
30
|
+
export function SanvikaAuthProvider({
|
|
31
|
+
children,
|
|
32
|
+
clientId,
|
|
33
|
+
redirectUri,
|
|
34
|
+
iamUrl = DEFAULT_IAM_URL,
|
|
35
|
+
dashboardPath = "/dashboard",
|
|
36
|
+
}) {
|
|
37
|
+
const [user, setUser] = useState(null);
|
|
38
|
+
const [accessToken, setToken] = useState(null);
|
|
39
|
+
const [loading, setLoading] = useState(true); // true during initial localStorage read
|
|
40
|
+
|
|
41
|
+
// ── Hydrate from localStorage on mount ────────────────────────────────
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
try {
|
|
44
|
+
const storedToken = localStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
|
|
45
|
+
const storedUser = localStorage.getItem(STORAGE_KEYS.USER);
|
|
46
|
+
if (storedToken && storedUser) {
|
|
47
|
+
setToken(storedToken);
|
|
48
|
+
setUser(JSON.parse(storedUser));
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// corrupted storage — clear it
|
|
52
|
+
localStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN);
|
|
53
|
+
localStorage.removeItem(STORAGE_KEYS.USER);
|
|
54
|
+
} finally {
|
|
55
|
+
setLoading(false);
|
|
56
|
+
}
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
// ── Cross-tab sync: ek tab logout kare → sab tabs logout ──────────────
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
function onStorage(e) {
|
|
62
|
+
if (e.key === STORAGE_KEYS.ACCESS_TOKEN && !e.newValue) {
|
|
63
|
+
setUser(null);
|
|
64
|
+
setToken(null);
|
|
65
|
+
}
|
|
66
|
+
// New login in another tab
|
|
67
|
+
if (e.key === STORAGE_KEYS.ACCESS_TOKEN && e.newValue) {
|
|
68
|
+
try {
|
|
69
|
+
const u = localStorage.getItem(STORAGE_KEYS.USER);
|
|
70
|
+
if (u) setUser(JSON.parse(u));
|
|
71
|
+
setToken(e.newValue);
|
|
72
|
+
} catch {
|
|
73
|
+
/* ignore */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
window.addEventListener("storage", onStorage);
|
|
78
|
+
return () => window.removeEventListener("storage", onStorage);
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
// ── login: called after callback route gets tokens ────────────────────
|
|
82
|
+
const login = useCallback((token, userData) => {
|
|
83
|
+
localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, token);
|
|
84
|
+
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(userData));
|
|
85
|
+
setToken(token);
|
|
86
|
+
setUser(userData);
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
// ── logout: clear state + localStorage ────────────────────────────────
|
|
90
|
+
const logout = useCallback(async () => {
|
|
91
|
+
const token = localStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
|
|
92
|
+
// Optionally revoke on server (fire-and-forget)
|
|
93
|
+
if (token) {
|
|
94
|
+
fetch(`${iamUrl}/api/auth/logout`, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "Content-Type": "application/json" },
|
|
97
|
+
body: JSON.stringify({ logout_all: false }),
|
|
98
|
+
credentials: "include",
|
|
99
|
+
}).catch(() => {});
|
|
100
|
+
}
|
|
101
|
+
localStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN);
|
|
102
|
+
localStorage.removeItem(STORAGE_KEYS.USER);
|
|
103
|
+
setUser(null);
|
|
104
|
+
setToken(null);
|
|
105
|
+
}, [iamUrl]);
|
|
106
|
+
|
|
107
|
+
// ── updateUser: partial state update (e.g. image changed) ─────────────
|
|
108
|
+
const updateUser = useCallback((partial) => {
|
|
109
|
+
setUser((prev) => {
|
|
110
|
+
if (!prev) return prev;
|
|
111
|
+
const merged = { ...prev, ...partial };
|
|
112
|
+
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(merged));
|
|
113
|
+
return merged;
|
|
114
|
+
});
|
|
115
|
+
}, []);
|
|
116
|
+
|
|
117
|
+
// ── Redirect to Sanvika SSO ────────────────────────────────────────────
|
|
118
|
+
const redirectToLogin = useCallback(
|
|
119
|
+
(returnPath) => {
|
|
120
|
+
// Save current path so we can return after login
|
|
121
|
+
if (returnPath) {
|
|
122
|
+
localStorage.setItem(STORAGE_KEYS.RETURN_PATH, returnPath);
|
|
123
|
+
}
|
|
124
|
+
// CSRF state
|
|
125
|
+
const state = Math.random().toString(36).slice(2);
|
|
126
|
+
localStorage.setItem(STORAGE_KEYS.STATE, state);
|
|
127
|
+
|
|
128
|
+
const url = new URL(`${iamUrl}/authorize`);
|
|
129
|
+
url.searchParams.set("client_id", clientId);
|
|
130
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
131
|
+
url.searchParams.set("response_type", "code");
|
|
132
|
+
url.searchParams.set("state", state);
|
|
133
|
+
// Pass app's current theme so SA authorize page can match it
|
|
134
|
+
const appTheme = localStorage.getItem("theme");
|
|
135
|
+
if (appTheme === "light" || appTheme === "dark") {
|
|
136
|
+
url.searchParams.set("theme", appTheme);
|
|
137
|
+
}
|
|
138
|
+
window.location.href = url.toString();
|
|
139
|
+
},
|
|
140
|
+
[iamUrl, clientId, redirectUri],
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<SanvikaAuthContext.Provider
|
|
145
|
+
value={{
|
|
146
|
+
user,
|
|
147
|
+
accessToken,
|
|
148
|
+
loading,
|
|
149
|
+
isLoggedIn: !!user,
|
|
150
|
+
login,
|
|
151
|
+
logout,
|
|
152
|
+
updateUser,
|
|
153
|
+
redirectToLogin,
|
|
154
|
+
clientId,
|
|
155
|
+
redirectUri,
|
|
156
|
+
iamUrl,
|
|
157
|
+
dashboardPath,
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
{children}
|
|
161
|
+
</SanvikaAuthContext.Provider>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── Hook ─────────────────────────────────────────────────────────────────
|
|
166
|
+
export function useSanvikaAuth() {
|
|
167
|
+
const ctx = useContext(SanvikaAuthContext);
|
|
168
|
+
if (!ctx)
|
|
169
|
+
throw new Error("useSanvikaAuth must be used inside <SanvikaAuthProvider>");
|
|
170
|
+
return ctx;
|
|
171
|
+
}
|
package/constants.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Sanvika Auth SDK — Storage Keys & Constants
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
// localStorage keys — use these everywhere (never hardcode strings)
|
|
6
|
+
export const STORAGE_KEYS = {
|
|
7
|
+
ACCESS_TOKEN: "sanvika_access_token",
|
|
8
|
+
USER: "sanvika_user",
|
|
9
|
+
STATE: "sanvika_oauth_state", // CSRF state for OAuth flow
|
|
10
|
+
RETURN_PATH: "sanvika_return_path", // Page to redirect back after login
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Default Sanvika Accounts base URL
|
|
14
|
+
export const DEFAULT_IAM_URL = "https://accounts.sanvikaproduction.com";
|
|
15
|
+
|
|
16
|
+
// Token key in response body from /api/auth/token
|
|
17
|
+
export const TOKEN_RESPONSE_KEYS = {
|
|
18
|
+
ACCESS_TOKEN: "access_token",
|
|
19
|
+
REFRESH_TOKEN: "refresh_token",
|
|
20
|
+
USER: "user",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// User statuses (matches Sanvika Accounts User schema)
|
|
24
|
+
export const USER_STATUS = {
|
|
25
|
+
ONBOARDING: "onboarding",
|
|
26
|
+
ACTIVE: "active",
|
|
27
|
+
SUSPENDED: "suspended",
|
|
28
|
+
DELETED: "deleted",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Default avatar shown when user has no image
|
|
32
|
+
export const DEFAULT_AVATAR_SVG = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40'%3E%3Ccircle cx='20' cy='20' r='20' fill='%23e5e7eb'/%3E%3Ccircle cx='20' cy='15' r='7' fill='%23adb5bd'/%3E%3Cellipse cx='20' cy='35' rx='12' ry='8' fill='%23adb5bd'/%3E%3C/svg%3E`;
|
package/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Sanvika Auth SDK — Main Barrel Export
|
|
3
|
+
// Export all public SDK components and utilities
|
|
4
|
+
// ============================================================
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
SanvikaAuthProvider,
|
|
8
|
+
SanvikaAuthContext,
|
|
9
|
+
useSanvikaAuth,
|
|
10
|
+
} from "./SanvikaAuthProvider.jsx";
|
|
11
|
+
export { default as SanvikaAccountButton } from "./SanvikaAccountButton.jsx";
|
|
12
|
+
export {
|
|
13
|
+
STORAGE_KEYS,
|
|
14
|
+
DEFAULT_IAM_URL,
|
|
15
|
+
DEFAULT_AVATAR_SVG,
|
|
16
|
+
} from "./constants.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sanvika/auth",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Sanvika Auth SDK — React components and hooks for Sanvika SSO integration",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.js",
|
|
9
|
+
"SanvikaAuthProvider.jsx",
|
|
10
|
+
"SanvikaAccountButton.jsx",
|
|
11
|
+
"SanvikaAccountButton.module.css",
|
|
12
|
+
"constants.js"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"react": ">=18",
|
|
19
|
+
"next": ">=14"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"sanvika",
|
|
23
|
+
"auth",
|
|
24
|
+
"sso",
|
|
25
|
+
"oauth",
|
|
26
|
+
"react",
|
|
27
|
+
"nextjs"
|
|
28
|
+
],
|
|
29
|
+
"author": "sanvika",
|
|
30
|
+
"license": "MIT"
|
|
31
|
+
}
|