Readme
# @sapec/tenalanding-sso
The browser half of signing in through the **TenaLanding** portal. A handful of functions, no framework
dependency — it works in Angular 21, Angular 22, React, or plain TypeScript.
The backend half is the NuGet package `TenaLanding.Integration.AspNetCore`: it runs the OpenID Connect
exchange with the portal, then sends the browser to your SPA's callback page with a one-time code in the URL
fragment, and raises `POST {prefix}/sso`, which exchanges that code for **your** application's own tokens.
---
## Install
```ini
# .npmrc — no credential; reading the feed is anonymous
@sapec:registry=https://nuget.sapec.md/npm/sapec-npm/
```
```jsonc
// package.json
"dependencies": {
"@sapec/tenalanding-sso": "^0.4.0"
}
```
---
## Use
Two places in your SPA.
**The sign-in button** on your own login page:
```ts
import { landingSignInUrl } from '@sapec/tenalanding-sso';
location.href = landingSignInUrl(apiBase); // https://your-api/api/landing/login
```
A person already signed in to the portal goes straight in; anyone else signs in to the portal first and comes
back. The portal's tile opens the same address.
**The callback page** (`Landing:CallbackUrl` on the backend, `/sso` by convention):
```ts
import { completeLandingSignIn, LandingSsoError } from '@sapec/tenalanding-sso';
try {
const auth = await completeLandingSignIn<AuthResponse>(`${apiBase}/api/landing/sso`);
// auth is exactly what your own login returns — store it the way you already do
} catch (error) {
showError(error instanceof LandingSsoError ? error.message : null);
}
```
Angular, in full:
```ts
@Component({ selector: 'app-landing-sso', templateUrl: './landing-sso.component.html' })
export class LandingSsoComponent implements OnInit {
private readonly auth = inject(AuthService);
protected readonly failed = signal(false);
protected readonly reason = signal<string | null>(null);
async ngOnInit(): Promise<void> {
try {
await this.auth.completePortalSignIn(); // calls completeLandingSignIn and stores the answer
} catch (error: unknown) {
this.reason.set(error instanceof LandingSsoError ? error.message : null);
this.failed.set(true);
}
}
}
```
**Never retry a failed redemption.** The code is single-use and lives 60 seconds; a retry is guaranteed to
fail. Show the message and a link to your normal sign-in page.
---
## API
### `completeLandingSignIn<T>(redeemUrl, options?): Promise<T>`
Reads the fragment and, in one call:
- `#code=…` — wipes it from history, redeems it with `redeemLandingCode` and returns your response;
- `#error=…&error_description=…` — wipes it from history and throws `LandingSsoError` carrying the portal's own
sentence (for example that the account was switched off in your application) and `reason` = the error code;
- neither — throws `LandingSsoError` with `reason` = `no_code` (the page was opened directly).
`options.source` replaces `window` in tests; `options.init` is merged into the `fetch` call.
### `landingSignInUrl(apiBase, routePrefix = '/api/landing'): string`
`{apiBase}{routePrefix}/login`, tolerant of trailing and leading slashes — the address that starts a sign-in.
### `readLandingCode(source?): string | null` and `redeemLandingCode<T>(redeemUrl, code, init?): Promise<T>`
The two building blocks `completeLandingSignIn` is made of, for when you need them separately. `readLandingCode`
returns the code and replaces the history entry; `redeemLandingCode` posts `{ "code": "…" }` and returns the
parsed body, or throws `LandingSsoError` with the `detail` (or `title`) of the problem your backend returned.
### `LandingSsoError`
`message` (the sentence to show), `status` (the HTTP status, `0` when nothing was sent) and `reason`
(`redeem_failed`, `no_code`, or the portal's error code such as `access_denied` or `not_configured`).