@tenminuteschool/auth-admin-react
v0.8.0
Published
OAuth 2.0 + PKCE browser client for Login with 10MinuteSchool (admin)
Readme
@tenminuteschool/auth-admin-react
Add "Login with 10MS Admin" to any React app in minutes.
AI Prompt
Copy and paste this into your AI assistant to get a full working implementation:
Implement "Login with 10MS Admin" in this project using @tenminuteschool/auth-admin-react.
Steps:
1. Install: npm install @tenminuteschool/auth-admin-react
2. Add to .env.local:
NEXT_PUBLIC_TENMS_CLIENT_ID=YOUR_CLIENT_ID
3. Create lib/auth.ts:
import { TenMSAuth } from '@tenminuteschool/auth-admin-react';
export const CLIENT_ID = process.env.NEXT_PUBLIC_TENMS_CLIENT_ID!;
export const auth = new TenMSAuth({ clientId: CLIENT_ID, storage: 'localStorage' });
4. Create app/providers.tsx (a "use client" component):
'use client';
import { TenMSAuthProvider } from '@tenminuteschool/auth-admin-react';
import { auth } from '@/lib/auth';
export function Providers({ children }) {
return <TenMSAuthProvider auth={auth}>{children}</TenMSAuthProvider>;
}
Then wrap {children} with <Providers> in app/layout.tsx (which stays a
Server Component — don't import TenMSAuthProvider there directly).
5. Create a login page that:
- Imports LoginButton and CLIENT_ID, and calls useTenMSAuth() to get
{ user, loading, refresh }
- If loading, render nothing yet. If user is already set, redirect to
/dashboard
- Shows the button with clientId={CLIENT_ID} and onSuccess handler
- In onSuccess: await auth.handleLoginSuccess(response), then call
refresh(), then redirect to /dashboard
6. On the dashboard page:
- Call useTenMSAuth() to get { user, loading, auth, refresh }
- If loading, render nothing yet. If no user, redirect back to the login
page
- Render user.name / user.email / user.picture
- Add a logout button that calls auth.logout(), then refresh(), then
redirects back to the login page
Do not hardcode the client ID anywhere. Do not change the storage value.
Do not import TenMSAuthProvider directly into app/layout.tsx or any other
Server Component — always go through the small "use client" wrapper from
step 4. Do not call auth.isLoggedIn() / auth.getUser() directly in components
to read session state — use useTenMSAuth() instead. Do not call
auth.handleTokenHandoff() yourself anywhere — TenMSAuthProvider already calls
it once, globally; calling it again races with that and can drop a handoff
token before the provider sees it. Do not call auth.handleLoginSuccess() or
auth.logout() without immediately calling refresh() right after — the
provider does not detect those on its own.Installation
npm install @tenminuteschool/auth-admin-react
# or
pnpm add @tenminuteschool/auth-admin-reactSetup
Add your client ID to .env.local:
NEXT_PUBLIC_TENMS_CLIENT_ID=your_client_id_hereThen create this file once at the root of your project:
// lib/auth.ts
import { TenMSAuth } from '@tenminuteschool/auth-admin-react';
export const CLIENT_ID = process.env.NEXT_PUBLIC_TENMS_CLIENT_ID!;
export const auth = new TenMSAuth({
clientId: CLIENT_ID,
storage: 'localStorage',
});Session provider
Wrap your app once with TenMSAuthProvider to get session state via a hook,
and to support signing a user in via a tenms_token URL param handed off
from another app (that app can redirect to any page in yours — the
provider handles it globally, wherever it lands, instead of you having to
check for it on every page yourself).
TenMSAuthProvider uses hooks, so it needs "use client" — but your root
layout doesn't have to become a client component to use it. Wrap it in your
own small client component instead:
// app/providers.tsx
'use client';
import { TenMSAuthProvider } from '@tenminuteschool/auth-admin-react';
import { auth } from '@/lib/auth';
export function Providers({ children }: { children: React.ReactNode }) {
return <TenMSAuthProvider auth={auth}>{children}</TenMSAuthProvider>;
}// app/layout.tsx — stays a Server Component
import { Providers } from './providers';
export default function RootLayout({ children }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}Then anywhere in the tree:
import { useTenMSAuth } from '@tenminuteschool/auth-admin-react';
const { auth, user, loading, error, refresh } = useTenMSAuth();| Field | Description |
|---|---|
| auth | The same TenMSAuth instance you passed to the provider |
| user | The current session's user, or null |
| loading | true until the initial session check resolves — gate session-dependent UI on this |
| error | Set if a tenms_token handoff was present but failed (invalid/expired/non-admin token) |
| refresh() | Re-reads the session into context state — call this right after auth.handleLoginSuccess() or auth.logout(), since those write to storage without the provider knowing |
Common mistakes:
- Importing
TenMSAuthProviderstraight intoapp/layout.tsxinstead of through the wrapper above — this breaks the build, since a Server Component can't render something that needs"use client"directly. - Reading session state with
auth.isLoggedIn()/auth.getUser()inside a component instead ofuseTenMSAuth()— this bypasses the provider's handling of an incomingtenms_tokenhandoff and itsloadingstate, and can flash the wrong UI. - Calling
auth.handleTokenHandoff()yourself anywhere — the provider already does this once, globally, on mount. A second call races it and can consume thetenms_tokenbefore the provider processes it. - Calling
auth.handleLoginSuccess()/auth.logout()without callingrefresh()right after — the provider has no other way to know storage changed.
Protecting a page
useRequireAuth() collapses the "redirect away if there's no session" guard
into one line, instead of hand-writing a useEffect on every protected page:
import { useRequireAuth } from '@tenminuteschool/auth-admin-react';
const { user, loading } = useRequireAuth();
if (loading || !user) return null;It waits for the initial session check (including any in-flight cross-app
handoff) to settle before redirecting, so it won't bounce someone who's about
to be signed in. Defaults to a full window.location.href = '/' redirect,
which works with zero framework dependency — pass your own router for a
client-side transition instead of a full page reload:
import { useRouter } from 'next/navigation';
import { useRequireAuth } from '@tenminuteschool/auth-admin-react';
const router = useRouter();
const { user, loading } = useRequireAuth({ redirectTo: '/', redirect: router.replace });Note this only protects the one page it's called on — there's no project-wide route list. Add it to whichever pages need a session; leave it off any page that should stay public.
Login Button
import { LoginButton, useTenMSAuth } from '@tenminuteschool/auth-admin-react';
const { auth, refresh } = useTenMSAuth();
<LoginButton
clientId={CLIENT_ID}
onSuccess={async (response) => {
const session = await auth.handleLoginSuccess(response);
// session.user → { sub, name, email, picture, ... }
refresh();
window.location.href = '/dashboard';
}}
onError={(err) => console.error(err)}
/>Control which login methods are shown
methods takes an array, same style as scopes. Defaults to ['google']:
// Google sign-in only (default — no prop needed)
<LoginButton clientId="..." onSuccess={...} />
<LoginButton clientId="..." onSuccess={...} methods={['google']} />
// Email/password only
<LoginButton clientId="..." onSuccess={...} methods={['email']} />
// Both
<LoginButton clientId="..." onSuccess={...} methods={['google', 'email']} />Button appearance
<LoginButton
clientId="..."
onSuccess={...}
theme="dark" // 'dark' (default) | 'light'
variant="fill" // 'fill' (default) | 'outline'
size="large" // 'large' (default) | 'medium' | 'small'
text="Sign in" // custom label
/>Reading the session
Prefer useTenMSAuth() (see above) inside components under TenMSAuthProvider.
The underlying TenMSAuth methods it wraps are also available directly —
useful outside React, or in the provider's own auth instance:
import { auth } from '@/lib/auth';
auth.isLoggedIn() // true/false — no network call
auth.getUser() // { sub, name, email, picture, ... } or null
await auth.getAccessToken() // valid token string, auto-refreshes if expired
await auth.getPermissions() // ['os.batch.view', ...] — reads storage, no request
await auth.getMyProfile() // your own full HR record
await auth.logout() // revokes token + clears sessionMy profile
getMyProfile() returns the signed-in admin's own full HR record —
designation, teams, branch, education, employment and personal details:
const profile = await auth.getMyProfile();
profile.name; // 'Test Admin'
profile.designation; // 'Engineer'
profile.teams; // [{ id: 't1', name: 'Platform' }]
profile.joining_date; // '2024-03-01'It takes no arguments and there's no way to ask for someone else's: the id comes from the session. The underlying endpoint does accept any admin's id, but this SDK is about the app's own signed-in user — reading other people's HR records belongs in an admin console, against the endpoint directly.
This is a different thing from getAdminMe(), which returns only the identity
claims (sub, name, email, picture) and is what the SDK itself uses to
validate a session.
Unset fields come back from the API as the literal string '(missing in get)'
rather than being omitted. The SDK strips those, so an unset field reads as
undefined and the sentinel never reaches your UI.
The declared fields cover the record as it stands, and anything the backend
adds later still arrives on the object — typed as unknown until it's declared
in the SDK:
const value = profile.some_new_field as string | undefined;Permissions
Login fetches the admin's permission list and stores it — handleLoginSuccess()
and handleTokenHandoff() both do this, so the API is called once, at login.
The names come back on the session:
const session = await auth.handleLoginSuccess(response);
session.permissions;
// ['os.batch.view', 'os.batch.update', 'erp.promo.create', ...]Read them again anywhere with getPermissions(), which serves the stored list
without a request:
const permissions = await auth.getPermissions();
if (permissions.includes('os.batch.view')) {
// ...
}It re-fetches only when nothing is stored — i.e. the request during login
failed. Pass force: true to refetch and re-store immediately after changing
someone's access, or accessToken to read the permissions of some other token
(a plain read-through that neither consults nor overwrites the stored list).
Revoked permissions are dropped rather than stored: the API returns them as
INACTIVE instead of omitting them, and keeping them would make a plain
includes() grant access that was explicitly taken away. So every name in the
list is genuinely granted, and membership alone is a valid check.
Storage
The names are stored in localStorage under _tenms_admin_permissions, as a
plain array:
["os.batch.view", "erp.promo.create"]Unlike the session keys, this ignores the storage option and always uses
localStorage.
Every login path rewrites it and logout() clears it, so it always belongs to
the current session. It is not otherwise re-validated: a token refresh leaves it
as-is, so a permission change lands at the next login (or a force: true call),
not before.
A failed permissions request never fails the login — it's logged, and the session is still established with an empty list. So if you gate access on having permissions, check the list rather than relying on login throwing:
<LoginButton
clientId={CLIENT_ID}
onSuccess={async (response) => {
const session = await auth.handleLoginSuccess(response);
if (session.permissions.length === 0) {
await auth.logout();
refresh();
alert('No permissions assigned to your account.');
return;
}
refresh();
window.location.href = '/dashboard';
}}
/>TenMSAuth options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| clientId | string | required | Your registered client ID |
| storage | 'cookie' \| 'localStorage' | 'cookie' | Use localStorage for SPAs, cookie for SSR |
| redirectUri | string | window.location.origin | Override only if needed |
| scopes | string[] | ['profile', 'email', 'phone'] | OAuth scopes |
| local | boolean | false | Point at the local environment instead of production |
| debug | boolean | false | Logs every OAuth step to the console |
Local environment
Pass local: true to point the SDK at the local environment instead of production.
Every request follows automatically — nothing else to configure.
export const auth = new TenMSAuth({
clientId: CLIENT_ID,
storage: 'localStorage',
local: true,
});Client IDs are not shared across environments — use the locally registered one when
local is on, and make sure your redirectUri is registered there too. Pair it with
debug: true and the init log line will print the resolved URLs.
