# OAuthConsent

> Authorization screen for a third-party app: app identity, signed-in account, requested scopes with sensitive badges, Allow and Deny, and the redirect host.

Source: https://docs.nasaqui.com/components/oauth-consent

## Install

```bash
npx shadcn@latest add https://docs.nasaqui.com/r/oauth-consent.json
```

The screen a third-party app sends people to. It names the app and its publisher, shows which account will be used
(with a switch button), lists what the app asks for, and offers Allow and Deny. Broad permissions are marked
"Sensitive".

## When to use

- The provider side of an OAuth or OpenID Connect flow.

## When not to use

- Signing in with a provider: use `OAuthButtons`.
- Managing granted apps later: use a connected-accounts list.

## Import

```tsx
import { OAuthConsent } from "@fadymondy/nasaq/web";
// inside this monorepo: "@nasaq/web"
```

## Quick start

```tsx
import { OAuthConsent } from "@fadymondy/nasaq/web";

export function Consent() {
  return (
    <OAuthConsent
      headingLevel={1}
      app={{ name: "Zapline", publisher: "by Zapline Inc." }}
      account={{ name: "Fady Mondy", email: "fady@example.com" }}
      scopes={[{ id: "profile", label: "Read your profile" }, { id: "write", label: "Edit tasks", sensitive: true }]}
      redirectHost="app.zapline.io"
      onAllow={async () => { await api.allow(); }}
      onDeny={async () => { await api.deny(); }}
    />
  );
}

declare const api: { allow(): Promise<void>; deny(): Promise<void> };
```

## Anatomy

```
OAuthConsent                     data-slot="oauth-consent" (section)
├─ app logo + name + publisher
├─ heading                       "Zapline wants access to your account"
├─ account row                   Avatar, name, email, Switch account
├─ scopes list                   label, description, Sensitive badge
├─ error Alert                   role="alert"
├─ Deny + Allow                  per-action loading
└─ redirect note + revoke hint
```

## API

**OAuthConsent**: every `section` prop except `children`, plus:

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `app` | `ConsentApp` | required | `{ name, logo?, publisher? }`. `logo` is a URL or a node; else the initial shows. |
| `scopes` | `ConsentScope[]` | required | `{ id, label, description?, sensitive? }`. |
| `account` | `ConsentAccount` | required | `{ name, email, avatar? }`. |
| `onAllow` | `() => Promise<AuthSubmitResult> \| AuthSubmitResult` | required | Return `{ error }` to show a failure. |
| `onDeny` | `() => Promise<AuthSubmitResult> \| AuthSubmitResult` | required | |
| `onSwitchAccount` | `() => void` | | Shows "Switch account". |
| `redirectHost` | `string` | | Shown in "You will be sent back to {host}". |
| `productName` | `string` | brand name | The product the account belongs to. |
| `headingLevel` | `1 \| 2 \| 3` | `2` | Use `1` when this is the page's main heading. |
| `labels` | `Partial<OAuthConsentLabels>` | English or Arabic | Uses `{app}`, `{product}`, `{host}`. |

## Examples

**Failure keeps both buttons available**

```tsx
import { OAuthConsent } from "@fadymondy/nasaq/web";

export function Failing() {
  return (
    <OAuthConsent
      app={{ name: "Zapline" }}
      account={{ name: "Fady", email: "fady@example.com" }}
      scopes={[{ id: "p", label: "Read your profile" }]}
      onAllow={async () => ({ error: "The app could not be reached. Try again." })}
      onDeny={async () => {}}
    />
  );
}
```

## Accessibility

- One heading at the level you choose; scopes are a real list. The sensitive badge is text, not colour alone.
- Allow and Deny are ordinary buttons; the one running shows a spinner and `aria-busy`, and both stay reachable
  by keyboard. Deny is not styled as a destructive action.
- Errors are announced through a `role="alert"` Alert.

## RTL & i18n

- Built-in English and Arabic strings follow the Nasaq locale (`useNasaq`). Pass `labels` to change any of them or to
  add another language; keys you omit keep the built-in text.
- Layout uses logical properties, so it mirrors under `dir="rtl"` with no extra work.
- The redirect host and the email are isolated left to right inside Arabic sentences. App logos are not mirrored.

## Styling & tokens

- Built from `Card`-like surfaces, `Avatar`, `Badge`, `Button` and `Alert`; tokens only.

## Do / Don't

- Do show the redirect host so phishing is easier to spot.
- Do mark broad permissions as sensitive.
- Don't pre-select or auto-approve.
- Don't hide the account being used.

## Related

- [`auth-layout`](https://docs.nasaqui.com/components/auth-layout)
- [`oauth-buttons`](https://docs.nasaqui.com/components/oauth-buttons)
- [`avatar`](https://docs.nasaqui.com/components/avatar)
- [`badge`](https://docs.nasaqui.com/components/badge)

## Lab

https://docs.nasaqui.com/?path=/docs/components-auth-oauth-consent--docs

## Code

### React

```tsx
import { OAuthConsent } from "@fadymondy/nasaq/web";

export function Consent() {
  return (
    <OAuthConsent
      headingLevel={1}
      app={{ name: "Zapline", publisher: "by Zapline Inc." }}
      account={{ name: "Fady Mondy", email: "fady@example.com" }}
      scopes={[{ id: "profile", label: "Read your profile" }, { id: "write", label: "Edit tasks", sensitive: true }]}
      redirectHost="app.zapline.io"
      onAllow={async () => { await api.allow(); }}
      onDeny={async () => { await api.deny(); }}
    />
  );
}

declare const api: { allow(): Promise<void>; deny(): Promise<void> };
```

### shadcn

```tsx
import { OAuthConsent } from "@/components/ui/oauth-consent";

export function Consent() {
  return (
    <OAuthConsent
      headingLevel={1}
      app={{ name: "Zapline", publisher: "by Zapline Inc." }}
      account={{ name: "Fady Mondy", email: "fady@example.com" }}
      scopes={[{ id: "profile", label: "Read your profile" }, { id: "write", label: "Edit tasks", sensitive: true }]}
      redirectHost="app.zapline.io"
      onAllow={async () => { await api.allow(); }}
      onDeny={async () => { await api.deny(); }}
    />
  );
}

declare const api: { allow(): Promise<void>; deny(): Promise<void> };
```

### Vue

```vue
<script setup lang="ts">
import { NqOAuthConsent } from "@fadymondy/nasaq/vue";

// Your API calls. Resolve { error } to show a failure.
async function allow() {
  await fetch("/api/oauth/allow", { method: "POST" });
}
async function deny() {
  await fetch("/api/oauth/deny", { method: "POST" });
}
</script>

<template>
  <NqOAuthConsent
    :heading-level="1"
    :app="{ name: 'Zapline', publisher: 'by Zapline Inc.' }"
    :account="{ name: 'Fady Mondy', email: 'fady@example.com' }"
    :scopes="[{ id: 'profile', label: 'Read your profile' }, { id: 'write', label: 'Edit tasks', sensitive: true }]"
    redirect-host="app.zapline.io"
    :on-allow="allow"
    :on-deny="deny"
  />
</template>
```

### Blade

```blade
{{-- Your listeners call your API; resolve { error } to show a failure. --}}
<x-nq::oauth-consent :app="['name' => 'Zapline', 'publisher' => 'by Zapline Inc.']" :account="['name' => 'Fady Mondy', 'email' => 'fady@example.com']"
    :scopes="[['id' => 'profile', 'label' => 'Read your profile'], ['id' => 'write', 'label' => 'Edit tasks', 'sensitive' => true]]" redirect-host="app.zapline.io" :heading-level="1"
    x-on:nq-oauth-allow="$event.detail.waitUntil(new Promise((done) => setTimeout(done, 300)))"
    x-on:nq-oauth-deny="$event.detail.waitUntil(new Promise((done) => setTimeout(() => done({ error: 'Could not deny right now.' }), 300)))" />
```

### HTML + Alpine

```html
<section data-slot="oauth-consent" aria-labelledby="oauth-consent-title-000001" x-data="nqOAuthConsent(JSON.parse('{\u0022failed\u0022:\u0022Something went wrong. Try again.\u0022}'))"
    x-bind:aria-busy="pendingKind ? 'true' : null" x-on:nq-oauth-allow="$event.detail.waitUntil(new Promise((done) =&gt; setTimeout(done, 300)))" x-on:nq-oauth-deny="$event.detail.waitUntil(new Promise((done) =&gt; setTimeout(() =&gt; done({ error: &#039;Could not deny right now.&#039; }), 300)))" class="flex w-full flex-col gap-5">
    <div class="flex flex-col items-start gap-3">
        <div data-slot="oauth-consent-app" class="flex items-center gap-3">
            <span class="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-card border border-border bg-secondary text-label text-secondary-foreground">
                                    <span aria-hidden="true">Z</span>
                            </span>
             <span class="text-caption text-muted-foreground">by Zapline Inc.</span>         </div>
        <h1 id="oauth-consent-title-000001" class="text-h3 text-foreground"><bdi>Zapline</bdi> wants to access your <bdi>Nasaq</bdi> account</h1>
    </div>
    <div data-slot="oauth-consent-account" class="flex items-center gap-3 rounded-card border border-border bg-card p-3">
        <span data-slot="avatar" x-data="nqAvatar(0)"
    class="inline-flex shrink-0 select-none items-center justify-center overflow-hidden bg-secondary align-middle font-medium text-secondary-foreground size-10 text-label rounded-full">
                    <span data-slot="avatar-fallback" class="flex size-full items-center justify-center" x-bind="fallback"
             role="img" aria-label="Fady Mondy" >FM</span>
    </span>
        <div class="flex min-w-0 flex-1 flex-col">
            <span class="text-caption text-muted-foreground">Signed in as</span>
            <span class="truncate text-label text-foreground">Fady Mondy</span>
            <bdi dir="ltr" class="truncate text-caption text-muted-foreground">fady@example.com</bdi>
        </div>
            </div>
    <div class="flex flex-col gap-2">
        <p class="text-body-sm text-foreground">This will let <bdi class="font-medium">Zapline</bdi>:</p>
        <ul data-slot="oauth-consent-scopes" class="flex flex-col divide-y divide-border rounded-card border border-border bg-card">
                            <li data-scope="profile" class="flex items-start gap-3 p-3">
                                            <svg aria-hidden="true" class="mt-0.5 size-4 shrink-0 text-nq-success-text" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M20 6 9 17l-5-5"/>
</svg>                                        <div class="flex min-w-0 flex-1 flex-col gap-0.5">
                        <span class="flex flex-wrap items-center gap-2 text-label text-foreground">
                            Read your profile
                                                    </span>
                                            </div>
                </li>
                            <li data-scope="write" class="flex items-start gap-3 p-3">
                                            <svg aria-hidden="true" class="mt-0.5 size-4 shrink-0 text-nq-warning-text" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>
  <path d="M12 8v4"/>
  <path d="M12 16h.01"/>
</svg>                                        <div class="flex min-w-0 flex-1 flex-col gap-0.5">
                        <span class="flex flex-wrap items-center gap-2 text-label text-foreground">
                            Edit tasks
                             <span data-slot="badge"     class="inline-flex h-5 shrink-0 items-center gap-1 whitespace-nowrap rounded-[4px] border px-1.5 text-caption font-medium [&_svg]:size-3 border-nq-warning/40 bg-nq-warning-soft text-nq-warning-text">Sensitive</span>
                         </span>
                                            </div>
                </li>
                    </ul>
    </div>
    <div data-slot="alert" data-tone="danger" role="alert"
        x-show="error" style="display: none" class="relative grid grid-cols-[auto_1fr_auto] items-start gap-x-3 rounded-card border p-3 text-start border-nq-danger/30 bg-nq-danger-soft">
    <svg aria-hidden="true" data-slot="alert-icon" class="mt-0.5 size-4 text-nq-danger-text" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <circle cx="12" cy="12" r="10"/>
  <path d="m15 9-6 6"/>
  <path d="m9 9 6 6"/>
</svg>    <div data-slot="alert-body" class="flex min-w-0 flex-col gap-0.5">
                            <div data-slot="alert-description" class="text-body-sm text-foreground"><span x-text="error"></span></div>
            </div>
    </div>
    <div class="grid grid-cols-2 gap-2">
        <button data-slot="oauth-consent-deny"
     type="button"                         x-on:click="run(&#039;deny&#039;)" x-bind:disabled="off(&#039;deny&#039;)" x-bind:data-disabled="off(&#039;deny&#039;) ? &#039;&#039; : null" x-bind:aria-busy="pendingKind === &#039;deny&#039; ? &#039;true&#039; : null" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border font-sans transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 border-border bg-card text-foreground hover:bg-nq-hover h-[calc(var(--nq-control)+8px)] px-5 text-body">
        <template x-if="pendingKind === 'deny'"><svg data-slot="spinner" aria-hidden="true" class="size-4 animate-spin motion-reduce:animate-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg></template>
            Deny</button>
        <button data-slot="oauth-consent-allow"
     type="button"                         x-on:click="run(&#039;allow&#039;)" x-bind:disabled="off(&#039;allow&#039;)" x-bind:data-disabled="off(&#039;allow&#039;) ? &#039;&#039; : null" x-bind:aria-busy="pendingKind === &#039;allow&#039; ? &#039;true&#039; : null" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--nq-action)_88%,var(--nq-fg))] h-[calc(var(--nq-control)+8px)] px-5 text-body">
        <template x-if="pendingKind === 'allow'"><svg data-slot="spinner" aria-hidden="true" class="size-4 animate-spin motion-reduce:animate-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg></template>
            Allow</button>
    </div>
    <p class="text-caption text-muted-foreground">
                    You will be sent to <bdi dir="ltr" class="font-medium text-foreground">app.zapline.io</bdi>.
                You can remove this access at any time in your account settings.
    </p>
</section>
```
