Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ Main apps:
- `apps/web-dashboard`: React/Vite dashboard
- `packages/common`: shared models, validation, middleware, encryption, DB/model utilities

SDKs:
- `sdks/urbackend-sdk`: Core TypeScript/JavaScript SDK (Framework-agnostic)
- `sdks/urbackend-react`: React SDK (`UrProvider`, `useUrContext`, UI components)
- `sdks/urbackend-python`: Official Python SDK (Requests-based)

Workspace scripts are defined in [package.json](/package.json).

## Important project rules
Expand Down Expand Up @@ -69,6 +74,7 @@ Behavior:
## Redis key patterns
- Refresh session: `project:auth:refresh:session:{tokenId}`
- OAuth state: `project:auth:oauth:state:{state}` (10min TTL)
- Social Refresh Exchange: `project:social-auth:refresh-exchange:{rtCode}` (Uses atomic `GETDEL` for concurrency safety)
- Mail count: `project:mail:count:{projectId}:{YYYY-MM}` (TTL = end of month)
- Do NOT change these patterns — existing sessions will break

Expand Down Expand Up @@ -144,11 +150,26 @@ cd apps/web-dashboard
npm run build
```

Run SDK tests:
```bash
# JS Core SDK
npm run test --workspace=@urbackend/sdk

# React SDK (Vitest)
npm run test --workspace=@urbackend/react

# Python SDK (pytest)
cd sdks/urbackend-python
pytest
```

## Testing expectations

Before shipping auth, RLS, or schema changes:
- run `apps/public-api` tests
- run `apps/dashboard-api` tests
- run `@urbackend/sdk` tests
- run `@urbackend/react` tests
- run `apps/web-dashboard` lint
- run `apps/web-dashboard` build when frontend changed

Expand Down
2 changes: 1 addition & 1 deletion apps/consumer/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "consumer",
"version": "1.0.0",
"version": "0.1.0",
"description": "",
"main": "src/app.js",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard-api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dashboard-api",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"license": "AGPL-3.0-only",
"main": "src/app.js",
Expand Down
2 changes: 1 addition & 1 deletion apps/public-api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "public-api",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"license": "AGPL-3.0-only",
"main": "src/app.js",
Expand Down
12 changes: 5 additions & 7 deletions apps/public-api/src/__tests__/userAuth.social.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ jest.mock('@urbackend/common', () => {
redis: {
set: jest.fn().mockResolvedValue('OK'),
get: jest.fn(),
getdel: jest.fn(),
del: jest.fn().mockResolvedValue(1),
},
Project: {
Expand Down Expand Up @@ -441,7 +442,7 @@ describe('public userAuth social auth', () => {
});

test('exchangeSocialRefreshToken returns refresh token and deletes exchange code', async () => {
redis.get.mockResolvedValueOnce(JSON.stringify({
redis.getdel.mockResolvedValueOnce(JSON.stringify({
token: 'issued_access_token',
refreshToken: 'issued_refresh_token',
}));
Expand All @@ -455,8 +456,7 @@ describe('public userAuth social auth', () => {

await controller.exchangeSocialRefreshToken(req, res);

expect(redis.get).toHaveBeenCalledWith('project:social-auth:refresh-exchange:code_123');
expect(redis.del).toHaveBeenCalledWith('project:social-auth:refresh-exchange:code_123');
expect(redis.getdel).toHaveBeenCalledWith('project:social-auth:refresh-exchange:code_123');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
success: true,
Expand All @@ -468,7 +468,7 @@ describe('public userAuth social auth', () => {
});

test('exchangeSocialRefreshToken rejects invalid or expired code', async () => {
redis.get.mockResolvedValueOnce(null);
redis.getdel.mockResolvedValueOnce(null);

const req = makeReq();
req.body = {
Expand All @@ -487,7 +487,7 @@ describe('public userAuth social auth', () => {
});

test('exchangeSocialRefreshToken rejects mismatched token and deletes exchange code', async () => {
redis.get.mockResolvedValueOnce(JSON.stringify({
redis.getdel.mockResolvedValueOnce(JSON.stringify({
token: 'expected_access_token',
refreshToken: 'issued_refresh_token',
}));
Expand All @@ -500,8 +500,6 @@ describe('public userAuth social auth', () => {
const res = makeRes();

await controller.exchangeSocialRefreshToken(req, res);

expect(redis.del).toHaveBeenCalledWith('project:social-auth:refresh-exchange:code_456');
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
success: false,
Expand Down
40 changes: 35 additions & 5 deletions apps/public-api/src/controllers/userAuth.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ const {
shouldExposeRefreshToken
} = require('../utils/refreshToken');

const checkUserSoftDeleted = (user) => {
if (user && user.isDeleted) {
const dateStr = user.deletedAt
? new Date(new Date(user.deletedAt).getTime() + 30 * 24 * 60 * 60 * 1000).toDateString()
: 'soon';
return `Your account is scheduled for deletion on ${dateStr}. Please contact the administrator to recover it.`;
}
return null;
};

const SOCIAL_PROVIDER_KEYS = ['github', 'google'];
const SOCIAL_STATE_TTL_SECONDS = 600;
const SOCIAL_REFRESH_EXCHANGE_TTL_SECONDS = 60;
Expand Down Expand Up @@ -514,6 +524,12 @@ const findOrCreateSocialUser = async ({ project, usersColConfig, Model, provider

let user = await Model.findOne({ [providerIdField]: profile.providerUserId });
if (user) {
const deletedMsg = checkUserSoftDeleted(user);
if (deletedMsg) {
const err = new Error(deletedMsg);
err.statusCode = 403;
throw err;
}
return { user, isNewUser: false, linkedByEmail: false };
}

Expand All @@ -525,6 +541,12 @@ const findOrCreateSocialUser = async ({ project, usersColConfig, Model, provider

user = await Model.findOne({ email: profile.email });
if (user) {
const deletedMsg = checkUserSoftDeleted(user);
if (deletedMsg) {
const err = new Error(deletedMsg);
err.statusCode = 403;
throw err;
}
// P1: Only link if provider email is verified; reject if unverified to prevent account takeover
if (!profile.emailVerified) {
const err = new Error(`Cannot link ${providerName} account: the provider email is not verified. Please verify your email with ${providerName} first.`);
Expand Down Expand Up @@ -891,7 +913,7 @@ module.exports.exchangeSocialRefreshToken = async (req, res) => {
}

const exchangeKey = getSocialRefreshExchangeKey(rtCode);
const rawExchange = await redis.get(exchangeKey);
const rawExchange = await redis.getdel(exchangeKey);
if (!rawExchange) {
return res.status(400).json({
success: false,
Expand All @@ -903,22 +925,19 @@ module.exports.exchangeSocialRefreshToken = async (req, res) => {
try {
parsedExchange = JSON.parse(rawExchange);
} catch (err) {
await redis.del(exchangeKey);
return res.status(400).json({
success: false,
message: 'Invalid or expired refresh token exchange code',
});
}

if (parsedExchange.token !== token || !parsedExchange.refreshToken) {
await redis.del(exchangeKey);
return res.status(403).json({
success: false,
message: 'Invalid refresh token exchange payload',
});
}

await redis.del(exchangeKey);
return res.status(200).json({
success: true,
data: {
Expand Down Expand Up @@ -957,6 +976,9 @@ module.exports.signup = async (req, res) => {
const existingUser = await Model.findOne({ email: normalizedEmail });

if (existingUser) {
const deletedMsg = checkUserSoftDeleted(existingUser);
if (deletedMsg) return res.status(403).json({ error: deletedMsg });

// Check if user is unverified. If so, we can trigger a resend instead of a hard error.
const verificationField = getVerificationField(usersColConfig);
const isVerified = verificationField ? !!existingUser[verificationField] : true;
Expand Down Expand Up @@ -1092,6 +1114,10 @@ module.exports.login = async (req, res, next) => {

const user = await Model.findOne({ email: normalizedEmail }).select('+password');

if (user && user.isDeleted) {
return sendAuthError(403, checkUserSoftDeleted(user));
}

if (!user) {
let failedStatus = { locked: false, retryAfterSeconds: 0, attempts: 0 };
try {
Expand Down Expand Up @@ -1207,6 +1233,9 @@ module.exports.publicProfile = async (req, res) => {
const user = await Model.findOne({ username }, { password: 0 }).lean();
if (!user) return res.status(404).json({ error: "User not found" });

const deletedMsg = checkUserSoftDeleted(user);
if (deletedMsg) return res.status(403).json({ error: deletedMsg });

const profile = sanitizePublicProfile(user, usersColConfig);
return res.json(profile);
} catch (err) {
Expand Down Expand Up @@ -1409,7 +1438,8 @@ module.exports.requestPasswordReset = async (req, res) => {
}

const user = await Model.findOne({ email: normalizedEmail });
if (!user) {

if (!user || (user && user.isDeleted)) {
await setPublicOtpCooldown(project._id, normalizedEmail, 'reset');
return res.json({ message: "If that email exists, a reset code has been sent." });
}
Expand Down
2 changes: 1 addition & 1 deletion apps/web-dashboard/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "web-dashboard",
"private": true,
"version": "0.10.0",
"version": "0.10.1",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
Expand Down
38 changes: 27 additions & 11 deletions apps/web-dashboard/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,10 @@
--header-height: 48px; /* Slimmer header */
--border-radius: 4px;

/* Glassmorphism - Darker & Smoother */
/* Glassmorphism - Premium & Smoother */
--glass-bg: rgba(10, 10, 10, 0.7);
--glass-border: rgba(255, 255, 255, 0.05);
--glass-backdrop: blur(12px);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-backdrop: blur(16px);
}

.light-mode {
Expand Down Expand Up @@ -191,11 +191,17 @@ a {
}

/* Glassmorphism Panel */
.glass-panel {
background: var(--glass-bg);
.glass-card {
background: var(--color-glass-card-bg);
backdrop-filter: var(--glass-backdrop);
border: 1px solid var(--glass-border);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
border: 1px solid var(--color-glass-card-border);
box-shadow: 0 4px 24px -8px rgba(0, 0, 0, 0.5);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

.glass-card:hover {
border-color: rgba(255, 255, 255, 0.12);
box-shadow: 0 12px 32px -12px rgba(0, 0, 0, 0.6);
}

/* Premium Card */
Expand All @@ -215,8 +221,16 @@ a {
/* --- PREMIUM DESIGN SYSTEM EXTENSIONS --- */
.glass-card {
background: var(--color-glass-card-bg);
backdrop-filter: blur(12px);
backdrop-filter: var(--glass-backdrop);
border: 1px solid var(--color-glass-card-border);
box-shadow: 0 4px 24px -8px rgba(0, 0, 0, 0.5);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

.glass-card:hover {
border-color: rgba(255, 255, 255, 0.12);
box-shadow: 0 12px 32px -12px rgba(0, 0, 0, 0.6);
transform: translateY(-2px);
}

.gradient-text {
Expand Down Expand Up @@ -341,7 +355,7 @@ a {
}

.btn:active {
transform: scale(0.98);
transform: scale(0.96);
}

.btn-primary {
Expand Down Expand Up @@ -637,7 +651,7 @@ a {

/* --- PROJECT NAVIGATION BAR --- */
.project-navbar {
background: var(--color-bg-card);
background: rgba(10, 10, 10, 0.75);
border-bottom: 1px solid var(--color-border);
padding: 0 2rem;
display: flex;
Expand All @@ -647,7 +661,8 @@ a {
position: sticky;
top: 0;
z-index: 110;
backdrop-filter: blur(10px);
backdrop-filter: blur(16px);
box-shadow: 0 4px 20px -8px rgba(0, 0, 0, 0.5);
}

.nav-left {
Expand Down Expand Up @@ -719,6 +734,7 @@ a {
height: 2px;
background: var(--color-primary);
border-radius: 2px 2px 0 0;
box-shadow: 0 -2px 10px rgba(62, 207, 142, 0.4);
}

/* Responsive Navigation */
Expand Down
14 changes: 13 additions & 1 deletion apps/web-dashboard/src/pages/ProjectDetails.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,19 @@ function ProjectDetails() {
<h2 style={{ fontSize: '1.25rem', marginBottom: '8px' }}>New {newKey.type} Key</h2>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8rem' }}>Copy this now. It won't be shown again.</p>
</div>
<code style={{ display: 'block', padding: '12px', background: '#000', borderRadius: '6px', fontSize: '0.85rem', wordBreak: 'break-all', marginBottom: '1.5rem', border: '1px solid var(--color-border)', color: 'var(--color-primary)' }}>{newKey.key}</code>
<div style={{ display: 'flex', alignItems: 'center', background: '#000', borderRadius: '6px', border: '1px solid var(--color-border)', marginBottom: '1.5rem', overflow: 'hidden' }}>
<code style={{ flex: 1, padding: '12px', fontSize: '0.85rem', wordBreak: 'break-all', color: 'var(--color-primary)', borderRight: '1px solid var(--color-border)' }}>{newKey.key}</code>
<button
onClick={() => {
navigator.clipboard.writeText(newKey.key);
toast.success("Copied to clipboard!");
}}
style={{ padding: '0 16px', background: 'transparent', border: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
title="Copy"
>
<Copy size={16} />
</button>
</div>
<button onClick={() => setNewKey(null)} className="btn btn-primary" style={{ width: '100%' }}>I've copied it</button>
</div>
</div>
Expand Down
24 changes: 24 additions & 0 deletions examples/react-sdk-demo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
Loading
Loading