diff --git a/OMICRON_VERSION b/OMICRON_VERSION index 5fe4ed630..f18fa05d6 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -80e992229b52cecb517a59a0f686f8916549343f +99ffcbe2b1f4bddc4be85e45d9d1a0d920e2201b diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index 96005fc75..ba3609b9b 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -55,6 +55,8 @@ export type Address = { export type AddressConfig = { /** The set of addresses assigned to the port configuration. */ addresses: Address[] + /** Link to assign the address to */ + linkName: Name } /** @@ -852,7 +854,11 @@ export type BgpPeer = { vlanId?: number | null } -export type BgpPeerConfig = { peers: BgpPeer[] } +export type BgpPeerConfig = { + /** Link that the peer is reachable on */ + linkName: Name + peers: BgpPeer[] +} /** * The current state of a BGP peer. @@ -1633,13 +1639,37 @@ export type DerEncodedKeyPair = { publicCert: string } +/** + * View of a device access token + */ +export type DeviceAccessToken = { + /** A unique, immutable, system-controlled identifier for the token. Note that this ID is not the bearer token itself, which starts with "oxide-token-" */ + id: string + timeCreated: Date + timeExpires?: Date | null +} + export type DeviceAccessTokenRequest = { clientId: string deviceCode: string grantType: string } -export type DeviceAuthRequest = { clientId: string } +/** + * A single page of results + */ +export type DeviceAccessTokenResultsPage = { + /** list of items on this page of results */ + items: DeviceAccessToken[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string | null +} + +export type DeviceAuthRequest = { + clientId: string + /** Optional lifetime for the access token in seconds. If not specified, the silo's max TTL will be used (if set). */ + ttlSeconds?: number | null +} export type DeviceAuthVerify = { userCode: string } @@ -2700,6 +2730,8 @@ export type LinkConfigCreate = { autoneg: boolean /** The requested forward-error correction method. If this is not specified, the standard FEC for the underlying media will be applied if it can be determined. */ fec?: LinkFec | null + /** Link name */ + linkName: Name /** The link-layer discovery protocol (LLDP) configuration for the link. */ lldp: LldpLinkConfigCreate /** Maximum transmission unit for the link. */ @@ -2725,7 +2757,7 @@ export type LldpLinkConfig = { /** The LLDP link name TLV. */ linkName?: string | null /** The LLDP management IP TLV. */ - managementIp?: IpNet | null + managementIp?: string | null /** The LLDP system description TLV. */ systemDescription?: string | null /** The LLDP system name TLV. */ @@ -3181,6 +3213,8 @@ export type Route = { * Route configuration data associated with a switch port configuration. */ export type RouteConfig = { + /** Link the route should be active on */ + linkName: Name /** The set of routes assigned to a switch port. */ routes: Route[] } @@ -3399,6 +3433,23 @@ The default is that no Fleet roles are conferred by any Silo roles unless there' timeModified: Date } +/** + * View of silo authentication settings + */ +export type SiloAuthSettings = { + /** Maximum lifetime of a device token in seconds. If set to null, users will be able to create tokens that do not expire. */ + deviceTokenMaxTtlSeconds?: number | null + siloId: string +} + +/** + * Updateable properties of a silo's settings. + */ +export type SiloAuthSettingsUpdate = { + /** Maximum lifetime of a device token in seconds. If set to null, users will be able to create tokens that do not expire. */ + deviceTokenMaxTtlSeconds: number | null +} + /** * The amount of provisionable resources for a Silo */ @@ -3878,6 +3929,8 @@ export type SwitchInterfaceKind = export type SwitchInterfaceConfigCreate = { /** What kind of switch interface this configuration represents. */ kind: SwitchInterfaceKind + /** Link the interface will be assigned to */ + linkName: Name /** Whether or not IPv6 is enabled. */ v6Enabled: boolean } @@ -3903,11 +3956,15 @@ export type SwitchPort = { /** * An IP address configuration for a port settings object. */ -export type SwitchPortAddressConfig = { +export type SwitchPortAddressView = { /** The IP address and prefix. */ address: IpNet /** The id of the address lot block this address is drawn from. */ addressLotBlockId: string + /** The id of the address lot this address is drawn from. */ + addressLotId: string + /** The name of the address lot this address is drawn from. */ + addressLotName: Name /** The interface name this address belongs to. */ interfaceName: string /** The port settings object this address configuration belongs to. */ @@ -3968,6 +4025,22 @@ export type SwitchPortConfigCreate = { geometry: SwitchPortGeometry } +/** + * Per-port tx-eq overrides. This can be used to fine-tune the transceiver equalization settings to improve signal integrity. + */ +export type TxEqConfig2 = { + /** Main tap */ + main?: number | null + /** Post-cursor tap1 */ + post1?: number | null + /** Post-cursor tap2 */ + post2?: number | null + /** Pre-cursor tap1 */ + pre1?: number | null + /** Pre-cursor tap2 */ + pre2?: number | null +} + /** * A link configuration for a port settings object. */ @@ -3978,16 +4051,16 @@ export type SwitchPortLinkConfig = { fec?: LinkFec | null /** The name of this link. */ linkName: string - /** The link-layer discovery protocol service configuration id for this link. */ - lldpLinkConfigId?: string | null + /** The link-layer discovery protocol service configuration for this link. */ + lldpLinkConfig?: LldpLinkConfig | null /** The maximum transmission unit for this link. */ mtu: number /** The port settings this link configuration belongs to. */ portSettingsId: string /** The configured speed of the link. */ speed: LinkSpeed - /** The tx_eq configuration id for this link. */ - txEqConfigId?: string | null + /** The tx_eq configuration for this link. */ + txEqConfig?: TxEqConfig2 | null } /** @@ -4007,7 +4080,7 @@ export type SwitchPortRouteConfig = { /** The route's destination network. */ dst: IpNet /** The route's gateway address. */ - gw: IpNet + gw: string /** The interface name this route configuration is assigned to. */ interfaceName: string /** The port settings object this route configuration belongs to. */ @@ -4019,19 +4092,55 @@ export type SwitchPortRouteConfig = { } /** - * A switch port settings identity whose id may be used to view additional details. + * This structure maps a port settings object to a port settings groups. Port settings objects may inherit settings from groups. This mapping defines the relationship between settings objects and the groups they reference. + */ +export type SwitchPortSettingsGroups = { + /** The id of a port settings group being referenced by a port settings object. */ + portSettingsGroupId: string + /** The id of a port settings object referencing a port settings group. */ + portSettingsId: string +} + +/** + * A switch port VLAN interface configuration for a port settings object. + */ +export type SwitchVlanInterfaceConfig = { + /** The switch interface configuration this VLAN interface configuration belongs to. */ + interfaceConfigId: string + /** The virtual network id for this interface that is used for producing and consuming 802.1Q Ethernet tags. This field has a maximum value of 4095 as 802.1Q tags are twelve bits. */ + vlanId: number +} + +/** + * This structure contains all port settings information in one place. It's a convenience data structure for getting a complete view of a particular port's settings. */ export type SwitchPortSettings = { + /** Layer 3 IP address settings. */ + addresses: SwitchPortAddressView[] + /** BGP peer settings. */ + bgpPeers: BgpPeer[] /** human-readable free-form text about a resource */ description: string + /** Switch port settings included from other switch port settings groups. */ + groups: SwitchPortSettingsGroups[] /** unique, immutable, system-controlled identifier for each resource */ id: string + /** Layer 3 interface settings. */ + interfaces: SwitchInterfaceConfig[] + /** Layer 2 link settings. */ + links: SwitchPortLinkConfig[] /** unique, mutable, user-controlled identifier for each resource */ name: Name + /** Layer 1 physical port settings. */ + port: SwitchPortConfig + /** IP route settings. */ + routes: SwitchPortRouteConfig[] /** timestamp when this resource was created */ timeCreated: Date /** timestamp when this resource was last modified */ timeModified: Date + /** Vlan interface settings. */ + vlanInterfaces: SwitchVlanInterfaceConfig[] } /** @@ -4039,79 +4148,47 @@ export type SwitchPortSettings = { */ export type SwitchPortSettingsCreate = { /** Addresses indexed by interface name. */ - addresses: Record + addresses: AddressConfig[] /** BGP peers indexed by interface name. */ - bgpPeers: Record + bgpPeers?: BgpPeerConfig[] description: string - groups: NameOrId[] + groups?: NameOrId[] /** Interfaces indexed by link name. */ - interfaces: Record + interfaces?: SwitchInterfaceConfigCreate[] /** Links indexed by phy name. On ports that are not broken out, this is always phy0. On a 2x breakout the options are phy0 and phy1, on 4x phy0-phy3, etc. */ - links: Record + links: LinkConfigCreate[] name: Name portConfig: SwitchPortConfigCreate /** Routes indexed by interface name. */ - routes: Record + routes?: RouteConfig[] } /** - * This structure maps a port settings object to a port settings groups. Port settings objects may inherit settings from groups. This mapping defines the relationship between settings objects and the groups they reference. + * A switch port settings identity whose id may be used to view additional details. */ -export type SwitchPortSettingsGroups = { - /** The id of a port settings group being referenced by a port settings object. */ - portSettingsGroupId: string - /** The id of a port settings object referencing a port settings group. */ - portSettingsId: string +export type SwitchPortSettingsIdentity = { + /** human-readable free-form text about a resource */ + description: string + /** unique, immutable, system-controlled identifier for each resource */ + id: string + /** unique, mutable, user-controlled identifier for each resource */ + name: Name + /** timestamp when this resource was created */ + timeCreated: Date + /** timestamp when this resource was last modified */ + timeModified: Date } /** * A single page of results */ -export type SwitchPortSettingsResultsPage = { +export type SwitchPortSettingsIdentityResultsPage = { /** list of items on this page of results */ - items: SwitchPortSettings[] + items: SwitchPortSettingsIdentity[] /** token used to fetch the next page of results (if any) */ nextPage?: string | null } -/** - * A switch port VLAN interface configuration for a port settings object. - */ -export type SwitchVlanInterfaceConfig = { - /** The switch interface configuration this VLAN interface configuration belongs to. */ - interfaceConfigId: string - /** The virtual network id for this interface that is used for producing and consuming 802.1Q Ethernet tags. This field has a maximum value of 4095 as 802.1Q tags are twelve bits. */ - vlanId: number -} - -/** - * This structure contains all port settings information in one place. It's a convenience data structure for getting a complete view of a particular port's settings. - */ -export type SwitchPortSettingsView = { - /** Layer 3 IP address settings. */ - addresses: SwitchPortAddressConfig[] - /** BGP peer settings. */ - bgpPeers: BgpPeer[] - /** Switch port settings included from other switch port settings groups. */ - groups: SwitchPortSettingsGroups[] - /** Layer 3 interface settings. */ - interfaces: SwitchInterfaceConfig[] - /** Link-layer discovery protocol (LLDP) settings. */ - linkLldp: LldpLinkConfig[] - /** Layer 2 link settings. */ - links: SwitchPortLinkConfig[] - /** Layer 1 physical port settings. */ - port: SwitchPortConfig - /** IP route settings. */ - routes: SwitchPortRouteConfig[] - /** The primary switch port settings handle. */ - settings: SwitchPortSettings - /** TX equalization settings. These are optional, and most links will not need them. */ - txEq: (TxEqConfig | null)[] - /** Vlan interface settings. */ - vlanInterfaces: SwitchVlanInterfaceConfig[] -} - /** * A single page of results */ @@ -5430,6 +5507,16 @@ export interface LoginLocalPathParams { siloName: Name } +export interface CurrentUserAccessTokenListQueryParams { + limit?: number | null + pageToken?: string | null + sortBy?: IdSortMode +} + +export interface CurrentUserAccessTokenDeletePathParams { + tokenId: string +} + export interface CurrentUserGroupsQueryParams { limit?: number | null pageToken?: string | null @@ -6885,6 +6972,30 @@ export class Api extends HttpClient { ...params, }) }, + /** + * Fetch current silo's auth settings + */ + authSettingsView: (_: EmptyObj, params: FetchParams = {}) => { + return this.request({ + path: `/v1/auth-settings`, + method: 'GET', + ...params, + }) + }, + /** + * Update current silo's auth settings + */ + authSettingsUpdate: ( + { body }: { body: SiloAuthSettingsUpdate }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/auth-settings`, + method: 'PUT', + body, + ...params, + }) + }, /** * List certificates for external endpoints */ @@ -7920,6 +8031,33 @@ export class Api extends HttpClient { ...params, }) }, + /** + * List access tokens + */ + currentUserAccessTokenList: ( + { query = {} }: { query?: CurrentUserAccessTokenListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/me/access-tokens`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Delete access token + */ + currentUserAccessTokenDelete: ( + { path }: { path: CurrentUserAccessTokenDeletePathParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/me/access-tokens/${path.tokenId}`, + method: 'DELETE', + ...params, + }) + }, /** * Fetch current user's groups */ @@ -9261,7 +9399,7 @@ export class Api extends HttpClient { { query = {} }: { query?: NetworkingSwitchPortSettingsListQueryParams }, params: FetchParams = {} ) => { - return this.request({ + return this.request({ path: `/v1/system/networking/switch-port-settings`, method: 'GET', query, @@ -9275,7 +9413,7 @@ export class Api extends HttpClient { { body }: { body: SwitchPortSettingsCreate }, params: FetchParams = {} ) => { - return this.request({ + return this.request({ path: `/v1/system/networking/switch-port-settings`, method: 'POST', body, @@ -9303,7 +9441,7 @@ export class Api extends HttpClient { { path }: { path: NetworkingSwitchPortSettingsViewPathParams }, params: FetchParams = {} ) => { - return this.request({ + return this.request({ path: `/v1/system/networking/switch-port-settings/${path.port}`, method: 'GET', ...params, diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index 023d451d7..907cbd5f8 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -80e992229b52cecb517a59a0f686f8916549343f +99ffcbe2b1f4bddc4be85e45d9d1a0d920e2201b diff --git a/app/api/__generated__/msw-handlers.ts b/app/api/__generated__/msw-handlers.ts index d47419ac7..096491ef6 100644 --- a/app/api/__generated__/msw-handlers.ts +++ b/app/api/__generated__/msw-handlers.ts @@ -331,6 +331,17 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable + /** `GET /v1/auth-settings` */ + authSettingsView: (params: { + req: Request + cookies: Record + }) => Promisable> + /** `PUT /v1/auth-settings` */ + authSettingsUpdate: (params: { + body: Json + req: Request + cookies: Record + }) => Promisable> /** `GET /v1/certificates` */ certificateList: (params: { query: Api.CertificateListQueryParams @@ -754,6 +765,18 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> + /** `GET /v1/me/access-tokens` */ + currentUserAccessTokenList: (params: { + query: Api.CurrentUserAccessTokenListQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `DELETE /v1/me/access-tokens/:tokenId` */ + currentUserAccessTokenDelete: (params: { + path: Api.CurrentUserAccessTokenDeletePathParams + req: Request + cookies: Record + }) => Promisable /** `GET /v1/me/groups` */ currentUserGroups: (params: { query: Api.CurrentUserGroupsQueryParams @@ -1341,13 +1364,13 @@ export interface MSWHandlers { query: Api.NetworkingSwitchPortSettingsListQueryParams req: Request cookies: Record - }) => Promisable> + }) => Promisable> /** `POST /v1/system/networking/switch-port-settings` */ networkingSwitchPortSettingsCreate: (params: { body: Json req: Request cookies: Record - }) => Promisable> + }) => Promisable> /** `DELETE /v1/system/networking/switch-port-settings` */ networkingSwitchPortSettingsDelete: (params: { query: Api.NetworkingSwitchPortSettingsDeleteQueryParams @@ -1359,7 +1382,7 @@ export interface MSWHandlers { path: Api.NetworkingSwitchPortSettingsViewPathParams req: Request cookies: Record - }) => Promisable> + }) => Promisable> /** `GET /v1/system/policy` */ systemPolicyView: (params: { req: Request @@ -2055,6 +2078,11 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { null ) ), + http.get('/v1/auth-settings', handler(handlers['authSettingsView'], null, null)), + http.put( + '/v1/auth-settings', + handler(handlers['authSettingsUpdate'], null, schema.SiloAuthSettingsUpdate) + ), http.get( '/v1/certificates', handler(handlers['certificateList'], schema.CertificateListParams, null) @@ -2385,6 +2413,22 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { ), http.post('/v1/logout', handler(handlers['logout'], null, null)), http.get('/v1/me', handler(handlers['currentUserView'], null, null)), + http.get( + '/v1/me/access-tokens', + handler( + handlers['currentUserAccessTokenList'], + schema.CurrentUserAccessTokenListParams, + null + ) + ), + http.delete( + '/v1/me/access-tokens/:tokenId', + handler( + handlers['currentUserAccessTokenDelete'], + schema.CurrentUserAccessTokenDeleteParams, + null + ) + ), http.get( '/v1/me/groups', handler(handlers['currentUserGroups'], schema.CurrentUserGroupsParams, null) diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 158b4bb52..3c54af9f8 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -93,7 +93,7 @@ export const Address = z.preprocess( */ export const AddressConfig = z.preprocess( processResponseBody, - z.object({ addresses: Address.array() }) + z.object({ addresses: Address.array(), linkName: Name }) ) /** @@ -818,7 +818,7 @@ export const BgpPeer = z.preprocess( export const BgpPeerConfig = z.preprocess( processResponseBody, - z.object({ peers: BgpPeer.array() }) + z.object({ linkName: Name, peers: BgpPeer.array() }) ) /** @@ -1536,14 +1536,37 @@ export const DerEncodedKeyPair = z.preprocess( z.object({ privateKey: z.string(), publicCert: z.string() }) ) +/** + * View of a device access token + */ +export const DeviceAccessToken = z.preprocess( + processResponseBody, + z.object({ + id: z.string().uuid(), + timeCreated: z.coerce.date(), + timeExpires: z.coerce.date().nullable().optional(), + }) +) + export const DeviceAccessTokenRequest = z.preprocess( processResponseBody, z.object({ clientId: z.string().uuid(), deviceCode: z.string(), grantType: z.string() }) ) +/** + * A single page of results + */ +export const DeviceAccessTokenResultsPage = z.preprocess( + processResponseBody, + z.object({ items: DeviceAccessToken.array(), nextPage: z.string().nullable().optional() }) +) + export const DeviceAuthRequest = z.preprocess( processResponseBody, - z.object({ clientId: z.string().uuid() }) + z.object({ + clientId: z.string().uuid(), + ttlSeconds: z.number().min(1).max(4294967295).nullable().optional(), + }) ) export const DeviceAuthVerify = z.preprocess( @@ -2533,6 +2556,7 @@ export const LinkConfigCreate = z.preprocess( z.object({ autoneg: SafeBoolean, fec: LinkFec.nullable().optional(), + linkName: Name, lldp: LldpLinkConfigCreate, mtu: z.number().min(0).max(65535), speed: LinkSpeed, @@ -2551,7 +2575,7 @@ export const LldpLinkConfig = z.preprocess( id: z.string().uuid(), linkDescription: z.string().nullable().optional(), linkName: z.string().nullable().optional(), - managementIp: IpNet.nullable().optional(), + managementIp: z.string().ip().nullable().optional(), systemDescription: z.string().nullable().optional(), systemName: z.string().nullable().optional(), }) @@ -3031,7 +3055,7 @@ export const Route = z.preprocess( */ export const RouteConfig = z.preprocess( processResponseBody, - z.object({ routes: Route.array() }) + z.object({ linkName: Name, routes: Route.array() }) ) /** @@ -3207,6 +3231,25 @@ export const Silo = z.preprocess( }) ) +/** + * View of silo authentication settings + */ +export const SiloAuthSettings = z.preprocess( + processResponseBody, + z.object({ + deviceTokenMaxTtlSeconds: z.number().min(0).max(4294967295).nullable().optional(), + siloId: z.string().uuid(), + }) +) + +/** + * Updateable properties of a silo's settings. + */ +export const SiloAuthSettingsUpdate = z.preprocess( + processResponseBody, + z.object({ deviceTokenMaxTtlSeconds: z.number().min(1).max(4294967295).nullable() }) +) + /** * The amount of provisionable resources for a Silo */ @@ -3613,7 +3656,7 @@ export const SwitchInterfaceKind = z.preprocess( */ export const SwitchInterfaceConfigCreate = z.preprocess( processResponseBody, - z.object({ kind: SwitchInterfaceKind, v6Enabled: SafeBoolean }) + z.object({ kind: SwitchInterfaceKind, linkName: Name, v6Enabled: SafeBoolean }) ) export const SwitchLinkState = z.preprocess(processResponseBody, z.record(z.unknown())) @@ -3635,11 +3678,13 @@ export const SwitchPort = z.preprocess( /** * An IP address configuration for a port settings object. */ -export const SwitchPortAddressConfig = z.preprocess( +export const SwitchPortAddressView = z.preprocess( processResponseBody, z.object({ address: IpNet, addressLotBlockId: z.string().uuid(), + addressLotId: z.string().uuid(), + addressLotName: Name, interfaceName: z.string(), portSettingsId: z.string().uuid(), vlanId: z.number().min(0).max(65535).nullable().optional(), @@ -3686,6 +3731,20 @@ export const SwitchPortConfigCreate = z.preprocess( z.object({ geometry: SwitchPortGeometry }) ) +/** + * Per-port tx-eq overrides. This can be used to fine-tune the transceiver equalization settings to improve signal integrity. + */ +export const TxEqConfig2 = z.preprocess( + processResponseBody, + z.object({ + main: z.number().min(-2147483647).max(2147483647).nullable().optional(), + post1: z.number().min(-2147483647).max(2147483647).nullable().optional(), + post2: z.number().min(-2147483647).max(2147483647).nullable().optional(), + pre1: z.number().min(-2147483647).max(2147483647).nullable().optional(), + pre2: z.number().min(-2147483647).max(2147483647).nullable().optional(), + }) +) + /** * A link configuration for a port settings object. */ @@ -3695,11 +3754,11 @@ export const SwitchPortLinkConfig = z.preprocess( autoneg: SafeBoolean, fec: LinkFec.nullable().optional(), linkName: z.string(), - lldpLinkConfigId: z.string().uuid().nullable().optional(), + lldpLinkConfig: LldpLinkConfig.nullable().optional(), mtu: z.number().min(0).max(65535), portSettingsId: z.string().uuid(), speed: LinkSpeed, - txEqConfigId: z.string().uuid().nullable().optional(), + txEqConfig: TxEqConfig2.nullable().optional(), }) ) @@ -3718,7 +3777,7 @@ export const SwitchPortRouteConfig = z.preprocess( processResponseBody, z.object({ dst: IpNet, - gw: IpNet, + gw: z.string().ip(), interfaceName: z.string(), portSettingsId: z.string().uuid(), ribPriority: z.number().min(0).max(255).nullable().optional(), @@ -3727,16 +3786,40 @@ export const SwitchPortRouteConfig = z.preprocess( ) /** - * A switch port settings identity whose id may be used to view additional details. + * This structure maps a port settings object to a port settings groups. Port settings objects may inherit settings from groups. This mapping defines the relationship between settings objects and the groups they reference. + */ +export const SwitchPortSettingsGroups = z.preprocess( + processResponseBody, + z.object({ portSettingsGroupId: z.string().uuid(), portSettingsId: z.string().uuid() }) +) + +/** + * A switch port VLAN interface configuration for a port settings object. + */ +export const SwitchVlanInterfaceConfig = z.preprocess( + processResponseBody, + z.object({ interfaceConfigId: z.string().uuid(), vlanId: z.number().min(0).max(65535) }) +) + +/** + * This structure contains all port settings information in one place. It's a convenience data structure for getting a complete view of a particular port's settings. */ export const SwitchPortSettings = z.preprocess( processResponseBody, z.object({ + addresses: SwitchPortAddressView.array(), + bgpPeers: BgpPeer.array(), description: z.string(), + groups: SwitchPortSettingsGroups.array(), id: z.string().uuid(), + interfaces: SwitchInterfaceConfig.array(), + links: SwitchPortLinkConfig.array(), name: Name, + port: SwitchPortConfig, + routes: SwitchPortRouteConfig.array(), timeCreated: z.coerce.date(), timeModified: z.coerce.date(), + vlanInterfaces: SwitchVlanInterfaceConfig.array(), }) ) @@ -3746,62 +3829,40 @@ export const SwitchPortSettings = z.preprocess( export const SwitchPortSettingsCreate = z.preprocess( processResponseBody, z.object({ - addresses: z.record(z.string().min(1), AddressConfig), - bgpPeers: z.record(z.string().min(1), BgpPeerConfig), + addresses: AddressConfig.array(), + bgpPeers: BgpPeerConfig.array().default([]).optional(), description: z.string(), - groups: NameOrId.array(), - interfaces: z.record(z.string().min(1), SwitchInterfaceConfigCreate), - links: z.record(z.string().min(1), LinkConfigCreate), + groups: NameOrId.array().default([]).optional(), + interfaces: SwitchInterfaceConfigCreate.array().default([]).optional(), + links: LinkConfigCreate.array(), name: Name, portConfig: SwitchPortConfigCreate, - routes: z.record(z.string().min(1), RouteConfig), + routes: RouteConfig.array().default([]).optional(), }) ) /** - * This structure maps a port settings object to a port settings groups. Port settings objects may inherit settings from groups. This mapping defines the relationship between settings objects and the groups they reference. - */ -export const SwitchPortSettingsGroups = z.preprocess( - processResponseBody, - z.object({ portSettingsGroupId: z.string().uuid(), portSettingsId: z.string().uuid() }) -) - -/** - * A single page of results + * A switch port settings identity whose id may be used to view additional details. */ -export const SwitchPortSettingsResultsPage = z.preprocess( +export const SwitchPortSettingsIdentity = z.preprocess( processResponseBody, z.object({ - items: SwitchPortSettings.array(), - nextPage: z.string().nullable().optional(), + description: z.string(), + id: z.string().uuid(), + name: Name, + timeCreated: z.coerce.date(), + timeModified: z.coerce.date(), }) ) /** - * A switch port VLAN interface configuration for a port settings object. - */ -export const SwitchVlanInterfaceConfig = z.preprocess( - processResponseBody, - z.object({ interfaceConfigId: z.string().uuid(), vlanId: z.number().min(0).max(65535) }) -) - -/** - * This structure contains all port settings information in one place. It's a convenience data structure for getting a complete view of a particular port's settings. + * A single page of results */ -export const SwitchPortSettingsView = z.preprocess( +export const SwitchPortSettingsIdentityResultsPage = z.preprocess( processResponseBody, z.object({ - addresses: SwitchPortAddressConfig.array(), - bgpPeers: BgpPeer.array(), - groups: SwitchPortSettingsGroups.array(), - interfaces: SwitchInterfaceConfig.array(), - linkLldp: LldpLinkConfig.array(), - links: SwitchPortLinkConfig.array(), - port: SwitchPortConfig, - routes: SwitchPortRouteConfig.array(), - settings: SwitchPortSettings, - txEq: TxEqConfig.nullable().array(), - vlanInterfaces: SwitchVlanInterfaceConfig.array(), + items: SwitchPortSettingsIdentity.array(), + nextPage: z.string().nullable().optional(), }) ) @@ -4913,6 +4974,22 @@ export const AntiAffinityGroupMemberInstanceDeleteParams = z.preprocess( }) ) +export const AuthSettingsViewParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({}), + }) +) + +export const AuthSettingsUpdateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({}), + }) +) + export const CertificateListParams = z.preprocess( processResponseBody, z.object({ @@ -5669,6 +5746,28 @@ export const CurrentUserViewParams = z.preprocess( }) ) +export const CurrentUserAccessTokenListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + limit: z.number().min(1).max(4294967295).nullable().optional(), + pageToken: z.string().nullable().optional(), + sortBy: IdSortMode.optional(), + }), + }) +) + +export const CurrentUserAccessTokenDeleteParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + tokenId: z.string().uuid(), + }), + query: z.object({}), + }) +) + export const CurrentUserGroupsParams = z.preprocess( processResponseBody, z.object({ diff --git a/app/components/DocsPopover.tsx b/app/components/DocsPopover.tsx index 5086597ff..88b6793b8 100644 --- a/app/components/DocsPopover.tsx +++ b/app/components/DocsPopover.tsx @@ -36,6 +36,7 @@ export const DocsPopoverLink = ({ href, linkText }: DocsPopoverLinkProps) => ( ) type DocsPopoverProps = { + /** Lower case because it appears as "Learn about ..." */ heading: string icon: JSX.Element links: Array diff --git a/app/layouts/ProjectLayoutBase.tsx b/app/layouts/ProjectLayoutBase.tsx index 5381ee959..32c0ed74f 100644 --- a/app/layouts/ProjectLayoutBase.tsx +++ b/app/layouts/ProjectLayoutBase.tsx @@ -70,7 +70,7 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { { value: 'VPCs', path: pb.vpcs(projectSelector) }, { value: 'Floating IPs', path: pb.floatingIps(projectSelector) }, { value: 'Affinity Groups', path: pb.affinity(projectSelector) }, - { value: 'Access', path: pb.projectAccess(projectSelector) }, + { value: 'Project Access', path: pb.projectAccess(projectSelector) }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -118,7 +118,7 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { Affinity Groups - Access + Project Access diff --git a/app/layouts/SettingsLayout.tsx b/app/layouts/SettingsLayout.tsx index a93076c8e..eb3238ca3 100644 --- a/app/layouts/SettingsLayout.tsx +++ b/app/layouts/SettingsLayout.tsx @@ -8,7 +8,12 @@ import { useMemo } from 'react' import { useLocation, useNavigate } from 'react-router' -import { Folder16Icon, Key16Icon, Profile16Icon } from '@oxide/design-system/icons/react' +import { + AccessToken16Icon, + Folder16Icon, + Key16Icon, + Profile16Icon, +} from '@oxide/design-system/icons/react' import { TopBar } from '~/components/TopBar' import { makeCrumb } from '~/hooks/use-crumbs' @@ -31,6 +36,7 @@ export default function SettingsLayout() { [ { value: 'Profile', path: pb.profile() }, { value: 'SSH Keys', path: pb.sshKeys() }, + { value: 'Access Tokens', path: pb.accessTokens() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -61,6 +67,9 @@ export default function SettingsLayout() { SSH Keys + + Access Tokens + diff --git a/app/layouts/SiloLayout.tsx b/app/layouts/SiloLayout.tsx index 166cb6761..361727119 100644 --- a/app/layouts/SiloLayout.tsx +++ b/app/layouts/SiloLayout.tsx @@ -36,7 +36,7 @@ export default function SiloLayout() { { value: 'Projects', path: pb.projects() }, { value: 'Images', path: pb.siloImages() }, { value: 'Utilization', path: pb.siloUtilization() }, - { value: 'Access', path: pb.siloAccess() }, + { value: 'Silo Access', path: pb.siloAccess() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -68,7 +68,7 @@ export default function SiloLayout() { Utilization - Access + Silo Access diff --git a/app/pages/SiloImagesPage.tsx b/app/pages/SiloImagesPage.tsx index 30b3a565b..3936c3593 100644 --- a/app/pages/SiloImagesPage.tsx +++ b/app/pages/SiloImagesPage.tsx @@ -104,7 +104,7 @@ export default function SiloImagesPage() { }>Silo Images } summary="Images let you create a new disk based on an existing one. Silo images must be created within a project and then promoted." links={[docLinks.images]} diff --git a/app/pages/project/images/ImagesPage.tsx b/app/pages/project/images/ImagesPage.tsx index e58c6bf97..6444caa70 100644 --- a/app/pages/project/images/ImagesPage.tsx +++ b/app/pages/project/images/ImagesPage.tsx @@ -110,7 +110,7 @@ export default function ImagesPage() { }>Project Images } summary="Images let you create a new disk based on an existing one. Images can be uploaded directly or created from a snapshot." links={[docLinks.images]} diff --git a/app/pages/project/vpcs/RouterPage.tsx b/app/pages/project/vpcs/RouterPage.tsx index 72f7e4c64..d9a25ba5d 100644 --- a/app/pages/project/vpcs/RouterPage.tsx +++ b/app/pages/project/vpcs/RouterPage.tsx @@ -181,7 +181,7 @@ export default function RouterPage() { }>{router}
} summary="Routers are collections of routes that direct traffic between VPCs and their subnets." links={[docLinks.routers]} diff --git a/app/pages/settings/AccessTokensPage.tsx b/app/pages/settings/AccessTokensPage.tsx new file mode 100644 index 000000000..235fcbb46 --- /dev/null +++ b/app/pages/settings/AccessTokensPage.tsx @@ -0,0 +1,111 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { createColumnHelper } from '@tanstack/react-table' +import { useCallback, useMemo } from 'react' + +import { getListQFn, queryClient, useApiMutation, type DeviceAccessToken } from '@oxide/api' +import { AccessToken16Icon, AccessToken24Icon } from '@oxide/design-system/icons/react' + +import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' +import { makeCrumb } from '~/hooks/use-crumbs' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +import { getActionsCol, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +import { DateTime } from '~/ui/lib/DateTime' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { TipIcon } from '~/ui/lib/TipIcon' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' + +const tokenList = () => getListQFn('currentUserAccessTokenList', {}) +export const handle = makeCrumb('Access Tokens', pb.accessTokens) + +export async function clientLoader() { + await queryClient.prefetchQuery(tokenList().optionsFn()) + return null +} + +const colHelper = createColumnHelper() + +export default function AccessTokensPage() { + const { mutateAsync: deleteToken } = useApiMutation('currentUserAccessTokenDelete', { + onSuccess: (_data, variables) => { + queryClient.invalidateEndpoint('currentUserAccessTokenList') + addToast(<>Access token {variables.path.tokenId} deleted) // prettier-ignore + }, + }) + + const makeActions = useCallback( + (token: DeviceAccessToken): MenuAction[] => [ + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => deleteToken({ path: { tokenId: token.id } }), + label: token.id, + extraContent: + 'This cannot be undone. Any application or instance of the Oxide CLI that depends on this token will need a new one.', + }), + }, + ], + [deleteToken] + ) + + const columns = useMemo(() => { + return [ + colHelper.accessor('id', { + header: () => ( + <> + ID + + A database ID for the token record, not the bearer token itself. + + + ), + cell: (info) => {info.getValue()}, + }), + colHelper.accessor('timeCreated', Columns.timeCreated), + colHelper.accessor('timeExpires', { + header: 'Expires', + cell: (info) => { + const date = info.getValue() + if (!date) return 'Never' + return + }, + }), + getActionsCol(makeActions), + ] + }, [makeActions]) + + const emptyState = ( + } + title="No access tokens" + body="Your access tokens will appear here when they are created" + /> + ) + const { table } = useQueryTable({ query: tokenList(), columns, emptyState }) + + return ( + <> + + }>Access Tokens + } + summary="Access tokens are used to authenticate API calls from the CLI and SDKs. You can list and delete tokens here. Use the CLI to generate new ones." + links={[docLinks.deviceTokens]} + /> + + {table} + + ) +} diff --git a/app/pages/settings/SSHKeysPage.tsx b/app/pages/settings/SSHKeysPage.tsx index e3a2fd26d..621977890 100644 --- a/app/pages/settings/SSHKeysPage.tsx +++ b/app/pages/settings/SSHKeysPage.tsx @@ -89,7 +89,7 @@ export default function SSHKeysPage() { } title="No SSH keys" - body="Add a SSH key to see it here" + body="Add an SSH key to see it here" buttonText="Add SSH key" onClick={() => navigate(pb.sshKeysNew())} /> diff --git a/app/routes.tsx b/app/routes.tsx index 576a1dc2f..f653b18af 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -110,6 +110,10 @@ export const routes = createRoutesFromElements( lazy={() => import('./pages/settings/ssh-key-create').then(convert)} /> + import('./pages/settings/AccessTokensPage').then(convert)} + /> import('./layouts/SystemLayout').then(convert)}> diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index d830358ce..2393aa324 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -2,6 +2,16 @@ exports[`breadcrumbs 2`] = ` { + "accessTokens (/settings/access-tokens)": [ + { + "label": "Settings", + "path": "/settings/profile", + }, + { + "label": "Access Tokens", + "path": "/settings/access-tokens", + }, + ], "affinity (/projects/p/affinity)": [ { "label": "Projects", diff --git a/app/util/links.ts b/app/util/links.ts index 1200a6163..913f9c6f1 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -14,6 +14,8 @@ export const links = { 'https://docs.oxide.computer/guides/deploying-workloads#_affinity_and_anti_affinity', cloudInitFormat: 'https://cloudinit.readthedocs.io/en/latest/explanation/format.html', cloudInitExamples: 'https://cloudinit.readthedocs.io/en/latest/reference/examples.html', + deviceTokenSetup: + 'https://docs.oxide.computer/guides/working-with-api-and-sdk#_device_token_setup', disksDocs: 'https://docs.oxide.computer/guides/managing-disks-and-snapshots', firewallRulesDocs: 'https://docs.oxide.computer/guides/configuring-guest-networking#_firewall_rules', @@ -73,6 +75,10 @@ export const docLinks = { href: links.affinityDocs, linkText: 'Anti-Affinity Groups', }, + deviceTokens: { + href: links.deviceTokenSetup, + linkText: 'Access Tokens', + }, disks: { href: links.disksDocs, linkText: 'Disks and Snapshots', diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index f43c78809..202994c02 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -41,6 +41,7 @@ test('path builder', () => { expect(Object.fromEntries(Object.entries(pb).map(([key, fn]) => [key, fn(params)]))) .toMatchInlineSnapshot(` { + "accessTokens": "/settings/access-tokens", "affinity": "/projects/p/affinity", "affinityNew": "/projects/p/affinity-new", "antiAffinityGroup": "/projects/p/affinity/aag", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 11ddc6ab3..1a75b7354 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -132,6 +132,7 @@ export const pb = { sshKeys: () => '/settings/ssh-keys', sshKeysNew: () => '/settings/ssh-keys-new', sshKeyEdit: (params: PP.SshKey) => `/settings/ssh-keys/${params.sshKey}/edit`, + accessTokens: () => '/settings/access-tokens', deviceSuccess: () => '/device/success', } diff --git a/mock-api/index.ts b/mock-api/index.ts index c7e12ab1a..ed6851294 100644 --- a/mock-api/index.ts +++ b/mock-api/index.ts @@ -24,6 +24,7 @@ export * from './sled' export * from './snapshot' export * from './sshKeys' export * from './switch' +export * from './token' export * from './user' export * from './user-group' export * from './vpc' diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 7e92af00b..cecc37e66 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -18,6 +18,7 @@ import type * as Sel from '~/api/selectors' import { commaSeries } from '~/util/str' import type { Json } from '../json-type' +import { siloSettings } from '../silo' import { internalError } from './util' export const notFoundErr = (msg: string) => { @@ -476,6 +477,7 @@ const initDb = { affinityGroupMemberLists: [...mock.affinityGroupMemberLists], antiAffinityGroups: [...mock.antiAffinityGroups], antiAffinityGroupMemberLists: [...mock.antiAffinityGroupMemberLists], + deviceTokens: [...mock.deviceTokens], disks: [...mock.disks], diskBulkImportState: new Map(), floatingIps: [...mock.floatingIps], @@ -499,6 +501,7 @@ const initDb = { silos: [...mock.silos], siloQuotas: [...mock.siloQuotas], siloProvisioned: [...mock.siloProvisioned], + siloSettings: [...siloSettings], identityProviders: [...mock.identityProviders], sleds: [...mock.sleds], switches: [...mock.switches], diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index bd7ccff7a..32ffce368 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1419,6 +1419,15 @@ export const handlers = makeHandlers({ return body }, + // assume every silo has a settings entry in both of these + authSettingsUpdate({ body }) { + const settings = db.siloSettings.find((s) => s.silo_id === defaultSilo.id)! + settings.device_token_max_ttl_seconds = body.device_token_max_ttl_seconds + return settings + }, + authSettingsView() { + return db.siloSettings.find((s) => s.silo_id === defaultSilo.id)! + }, rackList: ({ query, cookies }) => { requireFleetViewer(cookies) return paginated(query, db.racks) @@ -1457,6 +1466,11 @@ export const handlers = makeHandlers({ db.sshKeys = db.sshKeys.filter((i) => i.id !== sshKey.id) return 204 }, + currentUserAccessTokenDelete({ path }) { + db.deviceTokens = db.deviceTokens.filter((token) => token.id !== path.tokenId) + return 204 + }, + currentUserAccessTokenList: ({ query }) => paginated(query, db.deviceTokens), sledView({ path, cookies }) { requireFleetViewer(cookies) return lookup.sled(path) @@ -1498,6 +1512,7 @@ export const handlers = makeHandlers({ db.silos.push(newSilo) db.siloQuotas.push({ silo_id: newSilo.id, ...quotas }) db.siloProvisioned.push({ silo_id: newSilo.id, cpus: 0, memory: 0, storage: 0 }) + db.siloSettings.push({ silo_id: newSilo.id, device_token_max_ttl_seconds: null }) return json(newSilo, { status: 201 }) }, siloView({ path, cookies }) { @@ -1509,6 +1524,7 @@ export const handlers = makeHandlers({ const silo = lookup.silo(path) db.silos = db.silos.filter((i) => i.id !== silo.id) db.ipPoolSilos = db.ipPoolSilos.filter((i) => i.silo_id !== silo.id) + db.siloSettings = db.siloSettings.filter((i) => i.silo_id !== silo.id) return 204 }, siloIdentityProviderList({ query, cookies }) { diff --git a/mock-api/silo.ts b/mock-api/silo.ts index c764e0852..48fd8a551 100644 --- a/mock-api/silo.ts +++ b/mock-api/silo.ts @@ -7,7 +7,13 @@ */ import * as R from 'remeda' -import type { IdentityProvider, SamlIdentityProvider, Silo, SiloQuotas } from '@oxide/api' +import type { + IdentityProvider, + SamlIdentityProvider, + Silo, + SiloAuthSettings, + SiloQuotas, +} from '@oxide/api' import { GiB, TiB } from '~/util/units' @@ -111,3 +117,14 @@ export const toIdp = ({ provider, type }: DbIdp): Json => ({ provider_type: type, ...R.pick(provider, ['id', 'name', 'description', 'time_created', 'time_modified']), }) + +export const siloSettings: Json[] = [ + { + silo_id: defaultSilo.id, + device_token_max_ttl_seconds: 3600 * 24, // 1 hour in seconds + }, + { + silo_id: silos[1].id, + device_token_max_ttl_seconds: 7200, // 2 hours in seconds + }, +] diff --git a/mock-api/token.ts b/mock-api/token.ts new file mode 100644 index 000000000..c3ffd0a0e --- /dev/null +++ b/mock-api/token.ts @@ -0,0 +1,28 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import type { DeviceAccessToken } from '@oxide/api' + +import type { Json } from './json-type' + +export const deviceTokens: Json[] = [ + { + id: '6e762538-dd89-454e-b6e7-82a199b6e51a', + time_created: '2025-05-27T15:11:00Z', + time_expires: '2025-07-03T15:11:00Z', + }, + { + id: '9c858b30-bb11-4596-8c5e-c2bf1a26843e', + time_created: '2025-05-20T15:11:00Z', + time_expires: '2025-08-02T15:11:00Z', + }, + { + id: '29b1d980-e0d3-4318-b714-4a1f3e7b191f', + time_created: '2025-05-31T15:11:00Z', + time_expires: null, + }, +] diff --git a/package-lock.json b/package-lock.json index 21223b24d..74dd734ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@floating-ui/react": "^0.26.23", "@headlessui/react": "^2.2.0", - "@oxide/design-system": "^2.5.1", + "@oxide/design-system": "^2.7.0", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-tabs": "^1.1.0", @@ -1538,9 +1538,9 @@ "license": "MIT" }, "node_modules/@oxide/design-system": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-2.5.1.tgz", - "integrity": "sha512-QbuRsvqs5z4K4bkRRmYpWTb5Bo4BnDfJwsphqbWC/rIimcc5gMal3/8sBS8fk4Uq2tOJObKzMR05OQCUxyonbg==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-2.7.0.tgz", + "integrity": "sha512-W/RruOKkZ8q0aezWNpnO9FCTo+CWFSRzrl9afKzhGxnB1v9405c8Kx/JvlH5tXc6GK9uRuLKavDI6sN3Ci1d4Q==", "license": "MPL 2.0", "dependencies": { "@floating-ui/react": "^0.27.4", diff --git a/package.json b/package.json index 2ac361121..f86d57096 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "dependencies": { "@floating-ui/react": "^0.26.23", "@headlessui/react": "^2.2.0", - "@oxide/design-system": "^2.5.1", + "@oxide/design-system": "^2.7.0", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-tabs": "^1.1.0", diff --git a/test/e2e/access-tokens.e2e.ts b/test/e2e/access-tokens.e2e.ts new file mode 100644 index 000000000..2f3f80b92 --- /dev/null +++ b/test/e2e/access-tokens.e2e.ts @@ -0,0 +1,54 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { expect, test } from '@playwright/test' + +import { clickRowAction, expectRowVisible } from './utils' + +const token1 = '6e762538-dd89-454e-b6e7-82a199b6e51a' +const token2 = '9c858b30-bb11-4596-8c5e-c2bf1a26843e' +const token3 = '29b1d980-e0d3-4318-b714-4a1f3e7b191f' + +test('Access tokens', async ({ page }) => { + await page.goto('/') + + await page.getByLabel('User menu').click() + await page.getByRole('menuitem', { name: 'Settings' }).click() + await page.getByRole('link', { name: 'Access Tokens' }).click() + + await expect(page.getByRole('heading', { name: 'Access Tokens' })).toBeVisible() + + const table = page.getByRole('table') + await expectRowVisible(table, { + ID: token1, + created: expect.stringContaining('May 27, 2025'), + Expires: expect.stringContaining('Jul 3, 2025'), + }) + await expectRowVisible(table, { + ID: token2, + created: expect.stringContaining('May 20, 2025'), + Expires: expect.stringContaining('Aug 2, 2025'), + }) + await expectRowVisible(table, { + ID: token3, + created: expect.stringContaining('May 31, 2025'), + Expires: 'Never', + }) + + // Delete a token + await clickRowAction(page, token1, 'Delete') + await expect(page.getByRole('dialog').getByText('Cannot be undone')).toBeVisible() + await page.getByRole('button', { name: 'Confirm' }).click() + + // Token should be gone + await expect(page.getByRole('cell', { name: token1 })).toBeHidden() + + // Other two tokens should still be there + await expectRowVisible(table, { ID: token2 }) + await expectRowVisible(table, { ID: token3 }) +})