discourse/app/assets/stylesheets/common/components/d-otp.scss
Sam 5458a5f150
FEATURE: User API key device authorization flow (#40189)
Adds an OAuth-style device authorization flow for user API keys so
applications that can't open a browser (CLIs, headless tools, IoT
clients) can request a key by displaying a short user-facing code.

The client POSTs to `/user-api-key/device` to obtain a device code,
a user code, and a verification URL. The user visits the URL,
authenticates, confirms the application and scopes, and either
approves or denies the request. Meanwhile the client polls
`/user-api-key/device/poll` until it receives the encrypted key
payload, a denial, or expiry.

The flow is implemented as a `UserApiKey::DeviceAuth` namespace of
service objects (`CreateRequest`, `Authorize`, `Deny`, `Poll`,
`Store`, `Crypto`, `ApprovalTokenStore`, `GrantPresenter`). Pending
grants live in Redis with a short TTL and are rate limited per IP
and per user code. Encrypted payload generation is shared with the
existing redirect-based flow.

Also adds first-class expiration for user API keys:

- New `expires_at` column on `user_api_keys`.
- New `max_user_api_key_expiry_days` site setting (default 365).
- Clients can request a key lifetime via `expires_in_seconds`, which
  is surfaced to the user on the authorization screen and serialized
  back to the client.
- A `user_api_key` rake task for listing, inspecting, expiring, and
  revoking keys from the console.

---------

Co-authored-by: Penar Musaraj <pmusaraj@gmail.com>
2026-06-10 16:09:44 -04:00

109 lines
1.9 KiB
SCSS
Vendored

.d-otp {
display: flex;
align-items: center;
position: relative;
width: min-content;
.email-login & {
width: 100%;
justify-content: center;
}
}
.d-otp-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.d-otp-slot {
padding: var(--d-otp-slot-padding, 0.5em 0.65em);
font-size: var(--d-otp-slot-font-size, inherit);
line-height: normal;
display: flex;
align-items: center;
justify-content: center;
border: var(--d-input-border);
border-radius: var(--d-border-radius);
position: relative;
transition: all 0.3s ease-out;
width: 1em;
&.--is-focused {
@include default-focus;
}
&.--show-cursor {
// fake input blinking cursor
&::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
transform: translateX(-50%) translateY(-50%);
width: 1px;
height: 50%;
background-color: currentcolor;
animation: otp-cursor-blink 1s infinite;
}
}
&.--placeholder {
font-size: var(--font-up-0);
}
.form-kit__field.has-error & {
border-color: var(--danger);
}
}
.d-otp-separator {
color: var(--primary-medium);
font-size: var(--font-up-1);
font-weight: 700;
}
.d-otp-input-wrapper {
position: absolute;
inset: 0;
pointer-events: none;
}
.d-otp-input {
position: absolute;
inset: 0;
width: calc(100% + 40px);
height: 100%;
display: flex;
text-align: left;
opacity: 1;
color: transparent;
pointer-events: all;
background: transparent;
caret-color: transparent;
border: 0 solid transparent;
outline: transparent solid 0;
box-shadow: none;
line-height: 1;
letter-spacing: -0.5em;
font-size: var(--root-height);
font-family: monospace;
font-variant-numeric: tabular-nums;
clip-path: inset(0 40px 0 0);
&[data-com-onepassword-filled] {
background-clip: text;
}
}
@keyframes otp-cursor-blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}