mirror of
https://github.com/unraid/api.git
synced 2026-01-15 21:19:53 -06:00
feat(upgrade): implement upgrade onboarding system for Unraid OS
- Added `UpgradeInfo` type to track OS version changes, including current and previous versions. - Enhanced `InfoVersions` and GraphQL resolvers to expose upgrade information. - Introduced `upgradeOnboarding` store to manage visibility and steps for users upgrading their OS. - Updated `ActivationModal` to handle both fresh installs and upgrade onboarding, displaying relevant steps based on the user's upgrade path. - Created configuration for defining upgrade steps and conditions in `releaseConfigs.ts`. - Added new components and logic to facilitate the upgrade onboarding experience, improving user guidance during OS upgrades. This update streamlines the upgrade process, ensuring users receive contextual onboarding steps when upgrading their Unraid OS, enhancing overall user experience.
This commit is contained in:
@@ -5,5 +5,6 @@
|
||||
"ssoSubIds": [],
|
||||
"plugins": [
|
||||
"unraid-api-plugin-connect"
|
||||
]
|
||||
],
|
||||
"lastSeenOsVersion": "6.11.2"
|
||||
}
|
||||
@@ -1964,6 +1964,17 @@ type PackageVersions {
|
||||
docker: String
|
||||
}
|
||||
|
||||
type UpgradeInfo {
|
||||
"""Whether the OS version has changed since last boot"""
|
||||
isUpgrade: Boolean!
|
||||
|
||||
"""Previous OS version before upgrade"""
|
||||
previousVersion: String
|
||||
|
||||
"""Current OS version"""
|
||||
currentVersion: String
|
||||
}
|
||||
|
||||
type InfoVersions implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
@@ -1972,6 +1983,9 @@ type InfoVersions implements Node {
|
||||
|
||||
"""Software package versions"""
|
||||
packages: PackageVersions
|
||||
|
||||
"""OS upgrade information"""
|
||||
upgrade: UpgradeInfo!
|
||||
}
|
||||
|
||||
type Info implements Node {
|
||||
|
||||
@@ -560,6 +560,17 @@ export type CpuLoad = {
|
||||
percentUser: Scalars['Float']['output'];
|
||||
};
|
||||
|
||||
export type CpuPackages = Node & {
|
||||
__typename?: 'CpuPackages';
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Power draw per package (W) */
|
||||
power: Array<Scalars['Float']['output']>;
|
||||
/** Temperature per package (°C) */
|
||||
temp: Array<Scalars['Float']['output']>;
|
||||
/** Total CPU package power draw (W) */
|
||||
totalPower: Scalars['Float']['output'];
|
||||
};
|
||||
|
||||
export type CpuUtilization = Node & {
|
||||
__typename?: 'CpuUtilization';
|
||||
/** CPU load for each core */
|
||||
@@ -591,6 +602,19 @@ export type Customization = {
|
||||
theme: Theme;
|
||||
};
|
||||
|
||||
/** Customization related mutations */
|
||||
export type CustomizationMutations = {
|
||||
__typename?: 'CustomizationMutations';
|
||||
/** Update the UI theme (writes dynamix.cfg) */
|
||||
setTheme: Theme;
|
||||
};
|
||||
|
||||
|
||||
/** Customization related mutations */
|
||||
export type CustomizationMutationsSetThemeArgs = {
|
||||
theme: ThemeName;
|
||||
};
|
||||
|
||||
export type DeleteApiKeyInput = {
|
||||
ids: Array<Scalars['PrefixedID']['input']>;
|
||||
};
|
||||
@@ -1065,6 +1089,7 @@ export type InfoCpu = Node & {
|
||||
manufacturer?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU model */
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
packages: CpuPackages;
|
||||
/** Number of physical processors */
|
||||
processors?: Maybe<Scalars['Int']['output']>;
|
||||
/** CPU revision */
|
||||
@@ -1081,6 +1106,8 @@ export type InfoCpu = Node & {
|
||||
stepping?: Maybe<Scalars['Int']['output']>;
|
||||
/** Number of CPU threads */
|
||||
threads?: Maybe<Scalars['Int']['output']>;
|
||||
/** Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]] */
|
||||
topology: Array<Array<Array<Scalars['Int']['output']>>>;
|
||||
/** CPU vendor */
|
||||
vendor?: Maybe<Scalars['String']['output']>;
|
||||
/** CPU voltage */
|
||||
@@ -1282,6 +1309,8 @@ export type InfoVersions = Node & {
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Software package versions */
|
||||
packages?: Maybe<PackageVersions>;
|
||||
/** OS upgrade information */
|
||||
upgrade: UpgradeInfo;
|
||||
};
|
||||
|
||||
export type InitiateFlashBackupInput = {
|
||||
@@ -1422,6 +1451,7 @@ export type Mutation = {
|
||||
createDockerFolderWithItems: ResolvedOrganizerV1;
|
||||
/** Creates a new notification record */
|
||||
createNotification: Notification;
|
||||
customization: CustomizationMutations;
|
||||
/** Deletes all archived notifications on server. */
|
||||
deleteArchivedNotifications: NotificationOverview;
|
||||
deleteDockerEntries: ResolvedOrganizerV1;
|
||||
@@ -1454,6 +1484,8 @@ export type Mutation = {
|
||||
updateApiSettings: ConnectSettingsValues;
|
||||
updateDockerViewPreferences: ResolvedOrganizerV1;
|
||||
updateSettings: UpdateSettingsResponse;
|
||||
/** Update system time configuration */
|
||||
updateSystemTime: SystemTime;
|
||||
vm: VmMutations;
|
||||
};
|
||||
|
||||
@@ -1599,6 +1631,11 @@ export type MutationUpdateSettingsArgs = {
|
||||
input: Scalars['JSON']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateSystemTimeArgs = {
|
||||
input: UpdateSystemTimeInput;
|
||||
};
|
||||
|
||||
export type Network = Node & {
|
||||
__typename?: 'Network';
|
||||
accessUrls?: Maybe<Array<AccessUrl>>;
|
||||
@@ -1928,6 +1965,8 @@ export type Query = {
|
||||
services: Array<Service>;
|
||||
settings: Settings;
|
||||
shares: Array<Share>;
|
||||
/** Retrieve current system time configuration */
|
||||
systemTime: SystemTime;
|
||||
upsConfiguration: UpsConfiguration;
|
||||
upsDeviceById?: Maybe<UpsDevice>;
|
||||
upsDevices: Array<UpsDevice>;
|
||||
@@ -2269,6 +2308,7 @@ export type Subscription = {
|
||||
parityHistorySubscription: ParityCheck;
|
||||
serversSubscription: Server;
|
||||
systemMetricsCpu: CpuUtilization;
|
||||
systemMetricsCpuTelemetry: CpuPackages;
|
||||
systemMetricsMemory: MemoryUtilization;
|
||||
upsUpdates: UpsDevice;
|
||||
};
|
||||
@@ -2278,6 +2318,19 @@ export type SubscriptionLogFileArgs = {
|
||||
path: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
/** System time configuration and current status */
|
||||
export type SystemTime = {
|
||||
__typename?: 'SystemTime';
|
||||
/** Current server time in ISO-8601 format (UTC) */
|
||||
currentTime: Scalars['String']['output'];
|
||||
/** Configured NTP servers (empty strings indicate unused slots) */
|
||||
ntpServers: Array<Scalars['String']['output']>;
|
||||
/** IANA timezone identifier currently in use */
|
||||
timeZone: Scalars['String']['output'];
|
||||
/** Whether NTP/PTP time synchronization is enabled */
|
||||
useNtp: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
/** Tailscale exit node connection status */
|
||||
export type TailscaleExitNodeStatus = {
|
||||
__typename?: 'TailscaleExitNodeStatus';
|
||||
@@ -2548,6 +2601,27 @@ export enum UpdateStatus {
|
||||
UP_TO_DATE = 'UP_TO_DATE'
|
||||
}
|
||||
|
||||
export type UpdateSystemTimeInput = {
|
||||
/** Manual date/time to apply when disabling NTP, expected format YYYY-MM-DD HH:mm:ss */
|
||||
manualDateTime?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Ordered list of up to four NTP servers. Supply empty strings to clear positions. */
|
||||
ntpServers?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
/** New IANA timezone identifier to apply */
|
||||
timeZone?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Enable or disable NTP-based synchronization */
|
||||
useNtp?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
};
|
||||
|
||||
export type UpgradeInfo = {
|
||||
__typename?: 'UpgradeInfo';
|
||||
/** Current OS version */
|
||||
currentVersion?: Maybe<Scalars['String']['output']>;
|
||||
/** Whether the OS version has changed since last boot */
|
||||
isUpgrade: Scalars['Boolean']['output'];
|
||||
/** Previous OS version before upgrade */
|
||||
previousVersion?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Uptime = {
|
||||
__typename?: 'Uptime';
|
||||
timestamp?: Maybe<Scalars['String']['output']>;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './fragment-masking.js';
|
||||
export * from './gql.js';
|
||||
export * from "./fragment-masking.js";
|
||||
export * from "./gql.js";
|
||||
@@ -17,6 +17,7 @@ const createDefaultConfig = (): ApiConfig => ({
|
||||
sandbox: false,
|
||||
ssoSubIds: [],
|
||||
plugins: [],
|
||||
lastSeenOsVersion: undefined,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -87,6 +88,20 @@ export class ApiConfigPersistence
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
this.configService.set('api.version', API_VERSION);
|
||||
await this.trackOsVersionUpgrade();
|
||||
}
|
||||
|
||||
private async trackOsVersionUpgrade() {
|
||||
const currentOsVersion = this.configService.get<string>('store.emhttp.var.version');
|
||||
const lastSeenOsVersion = this.configService.get<string>('api.lastSeenOsVersion');
|
||||
|
||||
if (currentOsVersion && currentOsVersion !== lastSeenOsVersion) {
|
||||
this.configService.set('api.lastSeenOsVersion', currentOsVersion);
|
||||
const currentConfig = this.configService.get<ApiConfig>('api');
|
||||
if (currentConfig) {
|
||||
await this.persist(currentConfig);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async migrateConfig(): Promise<ApiConfig> {
|
||||
|
||||
@@ -41,6 +41,18 @@ export class PackageVersions {
|
||||
docker?: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class UpgradeInfo {
|
||||
@Field(() => Boolean, { description: 'Whether the OS version has changed since last boot' })
|
||||
isUpgrade!: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Previous OS version before upgrade' })
|
||||
previousVersion?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Current OS version' })
|
||||
currentVersion?: string;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class InfoVersions extends Node {
|
||||
@Field(() => CoreVersions, { description: 'Core system versions' })
|
||||
@@ -48,4 +60,7 @@ export class InfoVersions extends Node {
|
||||
|
||||
@Field(() => PackageVersions, { nullable: true, description: 'Software package versions' })
|
||||
packages?: PackageVersions;
|
||||
|
||||
@Field(() => UpgradeInfo, { description: 'OS upgrade information' })
|
||||
upgrade!: UpgradeInfo;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CoreVersions,
|
||||
InfoVersions,
|
||||
PackageVersions,
|
||||
UpgradeInfo,
|
||||
} from '@app/unraid-api/graph/resolvers/info/versions/versions.model.js';
|
||||
|
||||
@Resolver(() => InfoVersions)
|
||||
@@ -45,4 +46,20 @@ export class VersionsResolver {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ResolveField(() => UpgradeInfo)
|
||||
upgrade(): UpgradeInfo {
|
||||
const currentVersion = this.configService.get<string>('store.emhttp.var.version');
|
||||
const lastSeenVersion = this.configService.get<string>('api.lastSeenOsVersion');
|
||||
|
||||
const isUpgrade = Boolean(
|
||||
lastSeenVersion && currentVersion && lastSeenVersion !== currentVersion
|
||||
);
|
||||
|
||||
return {
|
||||
isUpgrade,
|
||||
previousVersion: isUpgrade ? lastSeenVersion : undefined,
|
||||
currentVersion: currentVersion || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,4 +26,9 @@ export class ApiConfig {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
plugins!: string[];
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastSeenOsVersion?: string;
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* ActivationSteps Component Test Coverage
|
||||
*/
|
||||
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import ActivationSteps from '~/components/Activation/ActivationSteps.vue';
|
||||
import { createTestI18n } from '../../utils/i18n';
|
||||
|
||||
interface Props {
|
||||
activeStep?: number;
|
||||
showActivationStep?: boolean;
|
||||
}
|
||||
|
||||
vi.mock('@unraid/ui', () => ({
|
||||
Stepper: {
|
||||
template: '<div data-testid="stepper" :default-value="defaultValue"><slot /></div>',
|
||||
props: ['defaultValue'],
|
||||
},
|
||||
StepperItem: {
|
||||
template: '<div data-testid="stepper-item"><slot :state="state" /></div>',
|
||||
props: ['step', 'disabled'],
|
||||
data() {
|
||||
return {
|
||||
state: 'active',
|
||||
};
|
||||
},
|
||||
},
|
||||
StepperTrigger: {
|
||||
template: '<div data-testid="stepper-trigger"><slot /></div>',
|
||||
},
|
||||
StepperTitle: {
|
||||
template: '<div data-testid="stepper-title"><slot /></div>',
|
||||
},
|
||||
StepperDescription: {
|
||||
template: '<div data-testid="stepper-description"><slot /></div>',
|
||||
},
|
||||
StepperSeparator: {
|
||||
template: '<div data-testid="stepper-separator"></div>',
|
||||
},
|
||||
Button: {
|
||||
template: '<button data-testid="button"><slot /></button>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@heroicons/vue/24/outline', () => ({
|
||||
CheckIcon: { template: '<div data-testid="check-icon" />' },
|
||||
ClockIcon: { template: '<div data-testid="clock-icon" />' },
|
||||
KeyIcon: { template: '<div data-testid="key-icon" />' },
|
||||
ServerStackIcon: { template: '<div data-testid="server-stack-icon" />' },
|
||||
}));
|
||||
|
||||
vi.mock('@heroicons/vue/24/solid', () => ({
|
||||
ClockIcon: { template: '<div data-testid="clock-icon-solid" />' },
|
||||
KeyIcon: { template: '<div data-testid="key-icon-solid" />' },
|
||||
LockClosedIcon: { template: '<div data-testid="lock-closed-icon" />' },
|
||||
ServerStackIcon: { template: '<div data-testid="server-stack-icon-solid" />' },
|
||||
}));
|
||||
|
||||
describe('ActivationSteps', () => {
|
||||
const mountComponent = (props: Props = {}) => {
|
||||
return mount(ActivationSteps, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it('renders all four steps with correct titles and descriptions', () => {
|
||||
const wrapper = mountComponent();
|
||||
const titles = wrapper.findAll('[data-testid="stepper-title"]');
|
||||
const descriptions = wrapper.findAll('[data-testid="stepper-description"]');
|
||||
|
||||
expect(titles).toHaveLength(4);
|
||||
expect(descriptions).toHaveLength(4);
|
||||
|
||||
expect(titles[0].text()).toBe('Create Device Password');
|
||||
expect(descriptions[0].text()).toBe('Secure your device');
|
||||
|
||||
expect(titles[1].text()).toBe('Configure Basic Settings');
|
||||
expect(descriptions[1].text()).toBe('Set up system preferences');
|
||||
|
||||
expect(titles[2].text()).toBe('Activate License');
|
||||
expect(descriptions[2].text()).toBe('Create an Unraid.net account and activate your key');
|
||||
|
||||
expect(titles[3].text()).toBe('Unleash Your Hardware');
|
||||
expect(descriptions[3].text()).toBe('Device is ready to configure');
|
||||
});
|
||||
|
||||
it('uses default activeStep of 1 when not provided', () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
expect(wrapper.find('[data-testid="stepper"]').attributes('default-value')).toBe('1');
|
||||
});
|
||||
|
||||
it('uses provided activeStep value', () => {
|
||||
const wrapper = mountComponent({ activeStep: 2 });
|
||||
|
||||
expect(wrapper.find('[data-testid="stepper"]').attributes('default-value')).toBe('2');
|
||||
});
|
||||
|
||||
it('hides activation step when showActivationStep is false', () => {
|
||||
const wrapper = mountComponent({ showActivationStep: false });
|
||||
const titles = wrapper.findAll('[data-testid="stepper-title"]');
|
||||
const descriptions = wrapper.findAll('[data-testid="stepper-description"]');
|
||||
|
||||
expect(titles).toHaveLength(3);
|
||||
expect(descriptions).toHaveLength(3);
|
||||
|
||||
expect(titles[0].text()).toBe('Create Device Password');
|
||||
expect(titles[1].text()).toBe('Configure Basic Settings');
|
||||
expect(titles[2].text()).toBe('Unleash Your Hardware');
|
||||
});
|
||||
});
|
||||
1
web/components.d.ts
vendored
1
web/components.d.ts
vendored
@@ -154,6 +154,7 @@ declare module 'vue' {
|
||||
USelectMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
|
||||
'UserProfile.standalone': typeof import('./src/components/UserProfile.standalone.vue')['default']
|
||||
USkeleton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Skeleton.vue')['default']
|
||||
UStepper: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Stepper.vue')['default']
|
||||
USwitch: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Switch.vue')['default']
|
||||
UTable: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Table.vue')['default']
|
||||
UTabs: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default']
|
||||
|
||||
@@ -19,6 +19,15 @@
|
||||
<span class="test-header-subtitle" id="page-title"></span>
|
||||
</div>
|
||||
<div class="test-header-right">
|
||||
<div class="server-state-selector">
|
||||
<label>Theme:</label>
|
||||
<select id="theme-select">
|
||||
<option value="white">White</option>
|
||||
<option value="black">Black</option>
|
||||
<option value="gray">Gray</option>
|
||||
<option value="azure">Azure</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="server-state-selector">
|
||||
<label>Server State:</label>
|
||||
<select id="server-state-select">
|
||||
@@ -356,23 +365,81 @@
|
||||
document.head.appendChild(baseFontStyle);
|
||||
};
|
||||
|
||||
// Function to load Unraid theme CSS files
|
||||
window.loadUnraidTheme = function (theme) {
|
||||
const selectedTheme = theme || localStorage.getItem('unraid-test-theme') || 'white';
|
||||
|
||||
// Save theme preference
|
||||
localStorage.setItem('unraid-test-theme', selectedTheme);
|
||||
|
||||
// Remove existing theme CSS
|
||||
document.querySelectorAll('[data-unraid-theme]').forEach((el) => el.remove());
|
||||
|
||||
const baseUrl =
|
||||
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/master/emhttp/plugins/dynamix/styles';
|
||||
|
||||
// Load CSS files in order
|
||||
const cssFiles = [
|
||||
{ href: `${baseUrl}/default-base.css`, id: 'unraid-base' },
|
||||
{ href: `${baseUrl}/default-dynamix.css`, id: 'unraid-dynamix' },
|
||||
{ href: `${baseUrl}/themes/${selectedTheme}.css`, id: 'unraid-theme' },
|
||||
];
|
||||
|
||||
cssFiles.forEach(({ href, id }) => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = href;
|
||||
link.id = id;
|
||||
link.setAttribute('data-unraid-theme', 'true');
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
|
||||
if (window.testLog) {
|
||||
window.testLog(`Loaded Unraid ${selectedTheme} theme`, 'info');
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-inject and initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
window.setBaseFontSize();
|
||||
window.loadUnraidTheme();
|
||||
window.injectSharedHeader();
|
||||
// Get page title from existing h1 or title tag
|
||||
const existingTitle =
|
||||
document.querySelector('h1')?.textContent ||
|
||||
document.title.replace(' - Unraid Component Test', '');
|
||||
window.initializeSharedHeader(existingTitle);
|
||||
|
||||
// Setup theme selector
|
||||
const themeSelect = document.getElementById('theme-select');
|
||||
if (themeSelect) {
|
||||
const savedTheme = localStorage.getItem('unraid-test-theme') || 'white';
|
||||
themeSelect.value = savedTheme;
|
||||
|
||||
themeSelect.addEventListener('change', (e) => {
|
||||
window.loadUnraidTheme(e.target.value);
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
window.setBaseFontSize();
|
||||
window.loadUnraidTheme();
|
||||
window.injectSharedHeader();
|
||||
const existingTitle =
|
||||
document.querySelector('h1')?.textContent ||
|
||||
document.title.replace(' - Unraid Component Test', '');
|
||||
window.initializeSharedHeader(existingTitle);
|
||||
|
||||
// Setup theme selector
|
||||
const themeSelect = document.getElementById('theme-select');
|
||||
if (themeSelect) {
|
||||
const savedTheme = localStorage.getItem('unraid-test-theme') || 'white';
|
||||
themeSelect.value = savedTheme;
|
||||
|
||||
themeSelect.addEventListener('change', (e) => {
|
||||
window.loadUnraidTheme(e.target.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -14,6 +14,7 @@ import ActivationSteps from '~/components/Activation/ActivationSteps.vue';
|
||||
import ActivationTimezoneStep from '~/components/Activation/ActivationTimezoneStep.vue';
|
||||
import { useActivationCodeDataStore } from '~/components/Activation/store/activationCodeData';
|
||||
import { useActivationCodeModalStore } from '~/components/Activation/store/activationCodeModal';
|
||||
import { useUpgradeOnboardingStore } from '~/components/Activation/store/upgradeOnboarding';
|
||||
import { usePurchaseStore } from '~/store/purchase';
|
||||
import { useThemeStore } from '~/store/theme';
|
||||
|
||||
@@ -21,14 +22,35 @@ const { t } = useI18n();
|
||||
|
||||
const modalStore = useActivationCodeModalStore();
|
||||
const { isVisible, isHidden } = storeToRefs(modalStore);
|
||||
const { partnerInfo, activationCode } = storeToRefs(useActivationCodeDataStore());
|
||||
const { partnerInfo, activationCode, isFreshInstall } = storeToRefs(useActivationCodeDataStore());
|
||||
const upgradeStore = useUpgradeOnboardingStore();
|
||||
const { shouldShowUpgradeOnboarding, upgradeSteps, currentVersion, previousVersion } =
|
||||
storeToRefs(upgradeStore);
|
||||
const purchaseStore = usePurchaseStore();
|
||||
|
||||
useThemeStore();
|
||||
|
||||
const hasActivationCode = computed(() => Boolean(activationCode.value?.code));
|
||||
|
||||
const currentStep = ref<'timezone' | 'plugins' | 'activation'>('timezone');
|
||||
const isUpgradeMode = computed(() => !isFreshInstall.value && shouldShowUpgradeOnboarding.value);
|
||||
|
||||
const showModal = computed(() => isVisible.value || shouldShowUpgradeOnboarding.value);
|
||||
|
||||
const availableSteps = computed(() => {
|
||||
if (isUpgradeMode.value) {
|
||||
return upgradeSteps.value.map((step) => step.id);
|
||||
}
|
||||
return ['timezone', 'plugins'];
|
||||
});
|
||||
|
||||
const currentStepIndex = ref(0);
|
||||
|
||||
const currentStep = computed(() => {
|
||||
if (currentStepIndex.value < availableSteps.value.length) {
|
||||
return availableSteps.value[currentStepIndex.value];
|
||||
}
|
||||
return hasActivationCode.value && !isUpgradeMode.value ? 'activation' : null;
|
||||
});
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[ActivationModal] Initial step:', currentStep.value);
|
||||
@@ -47,7 +69,11 @@ if (import.meta.env.DEV) {
|
||||
isVisible,
|
||||
};
|
||||
}
|
||||
|
||||
const activeStepNumber = computed(() => {
|
||||
if (isUpgradeMode.value) {
|
||||
return currentStepIndex.value + 1;
|
||||
}
|
||||
if (currentStep.value === 'timezone') {
|
||||
return 2;
|
||||
}
|
||||
@@ -57,10 +83,25 @@ const activeStepNumber = computed(() => {
|
||||
return hasActivationCode.value ? 4 : 3;
|
||||
});
|
||||
|
||||
const title = computed<string>(() => props.t("Let's activate your Unraid OS License"));
|
||||
const description = computed<string>(() =>
|
||||
t('activation.activationModal.onTheFollowingScreenYourLicense')
|
||||
);
|
||||
const modalTitle = computed<string>(() => {
|
||||
if (isUpgradeMode.value) {
|
||||
return t('Welcome to Unraid {version}!', { version: currentVersion.value });
|
||||
}
|
||||
return t("Let's activate your Unraid OS License");
|
||||
});
|
||||
|
||||
const modalDescription = computed<string>(() => {
|
||||
if (isUpgradeMode.value) {
|
||||
return t("You've upgraded from {prev} to {curr}", {
|
||||
prev: previousVersion.value,
|
||||
curr: currentVersion.value,
|
||||
});
|
||||
}
|
||||
return t(
|
||||
`On the following screen, your license will be activated. You'll then create an Unraid.net Account to manage your license going forward.`
|
||||
);
|
||||
});
|
||||
|
||||
const docsButtons = computed<BrandButtonProps[]>(() => {
|
||||
return [
|
||||
{
|
||||
@@ -82,94 +123,136 @@ const docsButtons = computed<BrandButtonProps[]>(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
if (isUpgradeMode.value) {
|
||||
upgradeStore.setIsHidden(true);
|
||||
} else {
|
||||
modalStore.setIsHidden(true);
|
||||
}
|
||||
};
|
||||
|
||||
const goToNextStep = () => {
|
||||
if (currentStepIndex.value < availableSteps.value.length - 1) {
|
||||
currentStepIndex.value++;
|
||||
} else if (hasActivationCode.value && !isUpgradeMode.value) {
|
||||
currentStepIndex.value = availableSteps.value.length;
|
||||
} else {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
const goToPreviousStep = () => {
|
||||
if (currentStepIndex.value > 0) {
|
||||
currentStepIndex.value--;
|
||||
}
|
||||
};
|
||||
|
||||
const canGoBack = computed(() => currentStepIndex.value > 0);
|
||||
|
||||
const handleTimezoneComplete = () => {
|
||||
console.log('[ActivationModal] Timezone complete, moving to plugins');
|
||||
currentStep.value = 'plugins';
|
||||
console.log('[ActivationModal] Timezone complete, moving to next step');
|
||||
goToNextStep();
|
||||
};
|
||||
|
||||
const handleTimezoneSkip = () => {
|
||||
currentStep.value = 'plugins';
|
||||
goToNextStep();
|
||||
};
|
||||
|
||||
const handlePluginsComplete = () => {
|
||||
if (hasActivationCode.value) {
|
||||
currentStep.value = 'activation';
|
||||
} else {
|
||||
modalStore.setIsHidden(true);
|
||||
}
|
||||
goToNextStep();
|
||||
};
|
||||
|
||||
const handlePluginsSkip = () => {
|
||||
if (hasActivationCode.value) {
|
||||
currentStep.value = 'activation';
|
||||
} else {
|
||||
modalStore.setIsHidden(true);
|
||||
}
|
||||
goToNextStep();
|
||||
};
|
||||
|
||||
const currentStepConfig = computed(() => {
|
||||
if (!isUpgradeMode.value) {
|
||||
return null;
|
||||
}
|
||||
return upgradeSteps.value[currentStepIndex.value];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
v-if="isVisible"
|
||||
:model-value="isVisible"
|
||||
v-if="showModal"
|
||||
:model-value="showModal"
|
||||
:show-footer="false"
|
||||
:show-close-button="isHidden === false"
|
||||
:show-close-button="isHidden === false || isUpgradeMode"
|
||||
size="full"
|
||||
class="bg-background"
|
||||
@update:model-value="(value) => !value && modalStore.setIsHidden(true)"
|
||||
@update:model-value="(value) => !value && closeModal()"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-start">
|
||||
<div v-if="partnerInfo?.hasPartnerLogo">
|
||||
<div v-if="partnerInfo?.hasPartnerLogo && !isUpgradeMode">
|
||||
<ActivationPartnerLogo :partner-info="partnerInfo" />
|
||||
</div>
|
||||
|
||||
<div v-if="isUpgradeMode" class="mt-6 mb-8 text-center">
|
||||
<h1 class="text-2xl font-semibold">{{ modalTitle }}</h1>
|
||||
<p class="mt-2 text-sm opacity-75">{{ modalDescription }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="currentStep === 'timezone'" class="flex w-full flex-col items-center">
|
||||
<div class="mt-6 flex w-full flex-col gap-6">
|
||||
<ActivationSteps
|
||||
:active-step="activeStepNumber"
|
||||
:show-activation-step="hasActivationCode"
|
||||
class="mb-6"
|
||||
/>
|
||||
|
||||
<div class="my-12">
|
||||
<ActivationTimezoneStep
|
||||
:t="t"
|
||||
:on-complete="handleTimezoneComplete"
|
||||
:on-skip="handleTimezoneSkip"
|
||||
:show-skip="false"
|
||||
:on-back="goToPreviousStep"
|
||||
:show-skip="isUpgradeMode ? !currentStepConfig?.required : false"
|
||||
:show-back="canGoBack"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ActivationSteps
|
||||
v-if="!isUpgradeMode"
|
||||
:active-step="activeStepNumber"
|
||||
:show-activation-step="hasActivationCode"
|
||||
class="mt-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentStep === 'plugins'" class="flex w-full flex-col items-center">
|
||||
<div class="mt-6 flex w-full flex-col gap-6">
|
||||
<ActivationSteps
|
||||
:active-step="activeStepNumber"
|
||||
:show-activation-step="hasActivationCode"
|
||||
class="mb-6"
|
||||
/>
|
||||
|
||||
<div class="my-12">
|
||||
<ActivationPluginsStep
|
||||
:t="t"
|
||||
:on-complete="handlePluginsComplete"
|
||||
:on-skip="handlePluginsSkip"
|
||||
:show-skip="hasActivationCode"
|
||||
:on-back="goToPreviousStep"
|
||||
:show-skip="isUpgradeMode ? !currentStepConfig?.required : hasActivationCode"
|
||||
:show-back="canGoBack"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ActivationSteps
|
||||
v-if="!isUpgradeMode"
|
||||
:active-step="activeStepNumber"
|
||||
:show-activation-step="hasActivationCode"
|
||||
class="mt-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentStep === 'activation'" class="flex w-full flex-col items-center">
|
||||
<h1 class="mt-4 text-center text-xl font-semibold sm:text-2xl">{{ title }}</h1>
|
||||
<h1 class="mt-4 text-center text-xl font-semibold sm:text-2xl">{{ modalTitle }}</h1>
|
||||
|
||||
<div class="mx-auto my-12 text-center sm:max-w-xl">
|
||||
<p class="text-center text-lg opacity-75 sm:text-xl">{{ description }}</p>
|
||||
<p class="text-center text-lg opacity-75 sm:text-xl">{{ modalDescription }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="mx-auto mb-10">
|
||||
<div class="mx-auto mb-10 flex gap-4">
|
||||
<BrandButton
|
||||
v-if="canGoBack"
|
||||
:text="t('Back')"
|
||||
variant="outline"
|
||||
@click="goToPreviousStep"
|
||||
/>
|
||||
<BrandButton
|
||||
:text="t('Activate Now')"
|
||||
:icon-right="ArrowTopRightOnSquareIcon"
|
||||
@@ -178,15 +261,15 @@ const handlePluginsSkip = () => {
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-col gap-6">
|
||||
<ActivationSteps
|
||||
:active-step="activeStepNumber"
|
||||
:show-activation-step="hasActivationCode"
|
||||
class="mb-6"
|
||||
/>
|
||||
|
||||
<div class="mx-auto flex w-full flex-col justify-center gap-4 sm:flex-row">
|
||||
<BrandButton v-for="button in docsButtons" :key="button.text" v-bind="button" />
|
||||
</div>
|
||||
|
||||
<ActivationSteps
|
||||
:active-step="activeStepNumber"
|
||||
:show-activation-step="hasActivationCode"
|
||||
class="mt-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,9 @@ export interface Props {
|
||||
t: ComposerTranslation;
|
||||
onComplete: () => void;
|
||||
onSkip?: () => void;
|
||||
onBack?: () => void;
|
||||
showSkip?: boolean;
|
||||
showBack?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
@@ -89,6 +91,10 @@ const handleInstall = async () => {
|
||||
const handleSkip = () => {
|
||||
props.onSkip?.();
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
props.onBack?.();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -124,6 +130,14 @@ const handleSkip = () => {
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<BrandButton
|
||||
v-if="onBack && showBack"
|
||||
:text="t('Back')"
|
||||
variant="outline"
|
||||
:disabled="isInstalling"
|
||||
@click="handleBack"
|
||||
/>
|
||||
<div class="flex-1" />
|
||||
<BrandButton
|
||||
v-if="onSkip && showSkip"
|
||||
:text="t('Skip')"
|
||||
|
||||
@@ -1,32 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import {
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
KeyIcon,
|
||||
PuzzlePieceIcon,
|
||||
ServerStackIcon,
|
||||
} from '@heroicons/vue/24/outline';
|
||||
import {
|
||||
ClockIcon as ClockIconSolid,
|
||||
KeyIcon as KeyIconSolid,
|
||||
LockClosedIcon,
|
||||
PuzzlePieceIcon as PuzzlePieceIconSolid,
|
||||
ServerStackIcon as ServerStackIconSolid,
|
||||
} from '@heroicons/vue/24/solid';
|
||||
import {
|
||||
Button,
|
||||
Stepper,
|
||||
StepperDescription,
|
||||
StepperItem,
|
||||
StepperTitle,
|
||||
StepperTrigger,
|
||||
} from '@unraid/ui';
|
||||
|
||||
import type { Component } from 'vue';
|
||||
|
||||
type StepState = 'inactive' | 'active' | 'completed';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -39,122 +12,69 @@ const props = withDefaults(
|
||||
}
|
||||
);
|
||||
|
||||
interface Step {
|
||||
step: number;
|
||||
interface StepItem {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: {
|
||||
inactive: Component;
|
||||
active: Component;
|
||||
completed: Component;
|
||||
};
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
const allSteps: readonly Step[] = [
|
||||
const allSteps: StepItem[] = [
|
||||
{
|
||||
step: 1,
|
||||
title: t('activation.activationSteps.createDevicePassword'),
|
||||
description: t('activation.activationSteps.secureYourDevice'),
|
||||
icon: {
|
||||
inactive: LockClosedIcon,
|
||||
active: LockClosedIcon,
|
||||
completed: CheckIcon,
|
||||
},
|
||||
title: 'Create Device Password',
|
||||
description: 'Secure your device',
|
||||
icon: 'i-heroicons-lock-closed',
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
title: 'Set Time Zone',
|
||||
description: 'Configure system time',
|
||||
icon: {
|
||||
inactive: ClockIcon,
|
||||
active: ClockIconSolid,
|
||||
completed: CheckIcon,
|
||||
},
|
||||
icon: 'i-heroicons-clock',
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
title: 'Install Essential Plugins',
|
||||
description: 'Add helpful plugins',
|
||||
icon: {
|
||||
inactive: PuzzlePieceIcon,
|
||||
active: PuzzlePieceIconSolid,
|
||||
completed: CheckIcon,
|
||||
},
|
||||
icon: 'i-heroicons-puzzle-piece',
|
||||
},
|
||||
{
|
||||
step: 4,
|
||||
title: 'Activate License',
|
||||
description: 'Create an Unraid.net account and activate your key',
|
||||
icon: {
|
||||
inactive: KeyIcon,
|
||||
active: KeyIconSolid,
|
||||
completed: CheckIcon,
|
||||
},
|
||||
icon: 'i-heroicons-key',
|
||||
},
|
||||
{
|
||||
step: 5,
|
||||
title: 'Unleash Your Hardware',
|
||||
description: 'Device is ready to configure',
|
||||
icon: {
|
||||
inactive: ServerStackIcon,
|
||||
active: ServerStackIconSolid,
|
||||
completed: CheckIcon,
|
||||
},
|
||||
icon: 'i-heroicons-server-stack',
|
||||
},
|
||||
] as const;
|
||||
];
|
||||
|
||||
const steps = computed(() => {
|
||||
if (props.showActivationStep) {
|
||||
return allSteps;
|
||||
}
|
||||
return allSteps
|
||||
.filter((step) => step.step !== 4)
|
||||
.map((step, index) => ({
|
||||
...step,
|
||||
step: index + 1,
|
||||
}));
|
||||
return allSteps.filter((_, index) => index !== 3);
|
||||
});
|
||||
|
||||
const currentStepIndex = computed(() => props.activeStep - 1);
|
||||
|
||||
const isMobile = ref(false);
|
||||
|
||||
const checkScreenSize = () => {
|
||||
isMobile.value = window.innerWidth < 768;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkScreenSize();
|
||||
window.addEventListener('resize', checkScreenSize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkScreenSize);
|
||||
});
|
||||
|
||||
const orientation = computed(() => (isMobile.value ? 'vertical' : 'horizontal'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Stepper :default-value="activeStep" class="text-foreground flex w-full items-start gap-2 text-base">
|
||||
<StepperItem
|
||||
v-for="step in steps"
|
||||
:key="step.step"
|
||||
v-slot="{ state }: { state: StepState }"
|
||||
class="relative flex w-full flex-col items-center justify-center data-disabled:opacity-100"
|
||||
:step="step.step"
|
||||
:disabled="true"
|
||||
>
|
||||
<StepperTrigger>
|
||||
<div class="flex items-center justify-center">
|
||||
<Button
|
||||
:variant="state === 'completed' ? 'primary' : state === 'active' ? 'primary' : 'outline'"
|
||||
size="md"
|
||||
:class="`z-10 rounded-full ${
|
||||
state !== 'inactive'
|
||||
? 'ring-offset-background ring-2 ring-offset-2 *:cursor-default ' +
|
||||
(state === 'completed' ? 'ring-success' : 'ring-primary')
|
||||
: ''
|
||||
}`"
|
||||
:disabled="state === 'inactive'"
|
||||
>
|
||||
<component :is="step.icon[state]" class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-col items-center text-center">
|
||||
<StepperTitle
|
||||
:class="[state === 'active' && 'text-primary']"
|
||||
class="text-2xs font-semibold transition"
|
||||
>
|
||||
{{ step.title }}
|
||||
</StepperTitle>
|
||||
<StepperDescription class="text-2xs font-normal">
|
||||
{{ step.description }}
|
||||
</StepperDescription>
|
||||
</div>
|
||||
</StepperTrigger>
|
||||
</StepperItem>
|
||||
</Stepper>
|
||||
<div class="mx-auto w-full max-w-4xl px-4">
|
||||
<UStepper :model-value="currentStepIndex" :items="steps" :orientation="orientation" class="w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -12,7 +12,9 @@ export interface Props {
|
||||
t: ComposerTranslation;
|
||||
onComplete: () => void;
|
||||
onSkip?: () => void;
|
||||
onBack?: () => void;
|
||||
showSkip?: boolean;
|
||||
showBack?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
@@ -91,6 +93,10 @@ const handleSubmit = async () => {
|
||||
const handleSkip = () => {
|
||||
props.onSkip?.();
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
props.onBack?.();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -114,6 +120,14 @@ const handleSkip = () => {
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<BrandButton
|
||||
v-if="onBack && showBack"
|
||||
:text="t('Back')"
|
||||
variant="outline"
|
||||
:disabled="isSaving"
|
||||
@click="handleBack"
|
||||
/>
|
||||
<div class="flex-1" />
|
||||
<BrandButton
|
||||
v-if="onSkip && showSkip"
|
||||
:text="t('Skip')"
|
||||
|
||||
135
web/src/components/Activation/UPGRADE_ONBOARDING.md
Normal file
135
web/src/components/Activation/UPGRADE_ONBOARDING.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Upgrade Onboarding System
|
||||
|
||||
## Overview
|
||||
|
||||
This system shows contextual onboarding steps to users when they upgrade their Unraid OS to a new version. It tracks the last seen OS version in the API config and allows you to define which steps should be shown for specific version upgrades.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Backend (API)
|
||||
|
||||
1. **Version Tracking** - `api/src/unraid-api/config/api-config.module.ts`
|
||||
- On boot, compares current OS version with `lastSeenOsVersion` in API config
|
||||
- Automatically updates `lastSeenOsVersion` when version changes
|
||||
- Persists to `/boot/config/modules/api.json`
|
||||
|
||||
2. **GraphQL API** - `api/src/unraid-api/graph/resolvers/info/versions/`
|
||||
- Exposes `info.versions.upgrade` field with:
|
||||
- `isUpgrade`: Boolean indicating if OS version changed
|
||||
- `previousVersion`: Last seen OS version
|
||||
- `currentVersion`: Current OS version
|
||||
|
||||
### Frontend (Web)
|
||||
|
||||
1. **Release Configuration** - `releaseConfigs.ts`
|
||||
- Define which steps to show for specific version upgrades
|
||||
- Support conditional steps with async functions
|
||||
- Example:
|
||||
|
||||
```typescript
|
||||
{
|
||||
version: '7.0.0',
|
||||
steps: [
|
||||
{ id: 'timezone', required: true },
|
||||
{
|
||||
id: 'plugins',
|
||||
required: false,
|
||||
condition: async () => {
|
||||
return checkSomeCondition();
|
||||
}
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
2. **Upgrade Onboarding Store** - `store/upgradeOnboarding.ts`
|
||||
- Queries upgrade info from GraphQL
|
||||
- Evaluates release config to determine which steps to show
|
||||
- Provides `shouldShowUpgradeOnboarding` computed property
|
||||
|
||||
3. **Unified Activation Modal** - `ActivationModal.vue`
|
||||
- Handles both fresh install and upgrade onboarding modes
|
||||
- Automatically detects which mode based on system state
|
||||
- Displays relevant steps for each mode
|
||||
- Reuses existing step components (timezone, plugins)
|
||||
- Persists "hidden" state per mode to session storage
|
||||
|
||||
## Adding New Steps
|
||||
|
||||
### 1. Create the Step Component
|
||||
|
||||
Create a new component like `ActivationTimezoneStep.vue` with:
|
||||
```vue
|
||||
<script setup>
|
||||
export interface Props {
|
||||
t: ComposerTranslation;
|
||||
onComplete: () => void;
|
||||
onSkip?: () => void;
|
||||
showSkip?: boolean;
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 2. Add Step ID Type
|
||||
|
||||
In `releaseConfigs.ts`, update the step ID type:
|
||||
```typescript
|
||||
export interface ReleaseStepConfig {
|
||||
id: 'timezone' | 'plugins' | 'your-new-step';
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Add Step to Modal
|
||||
|
||||
In `ActivationModal.vue`, add a section:
|
||||
|
||||
```vue
|
||||
<div v-else-if="currentStep === 'your-new-step'" class="flex w-full flex-col items-center">
|
||||
<YourNewStepComponent
|
||||
:t="t"
|
||||
:on-complete="goToNextStep"
|
||||
:on-skip="goToNextStep"
|
||||
:show-skip="isUpgradeMode ? !currentStepConfig?.required : false"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 4. Configure for Release
|
||||
|
||||
In `releaseConfigs.ts`, add to `releaseConfigs` array:
|
||||
|
||||
```typescript
|
||||
{
|
||||
version: '7.1.0',
|
||||
steps: [
|
||||
{
|
||||
id: 'your-new-step',
|
||||
required: true,
|
||||
condition: async () => {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
The `ActivationModal` is already integrated into the app and automatically handles both fresh install and upgrade modes. No additional setup needed!
|
||||
|
||||
## Testing
|
||||
|
||||
To test the upgrade flow:
|
||||
|
||||
1. Edit `/boot/config/modules/api.json` and set `lastSeenOsVersion` to an older version
|
||||
2. Restart the API
|
||||
3. The modal should appear on next page load with relevant steps
|
||||
|
||||
## Notes
|
||||
|
||||
- Fresh installs (no `lastSeenOsVersion`) won't trigger upgrade onboarding
|
||||
- The modal automatically switches between fresh install and upgrade modes
|
||||
- Each mode can be dismissed independently (stored in sessionStorage)
|
||||
- Version comparison uses semver for reliable ordering
|
||||
- The same modal component handles both modes for consistency
|
||||
82
web/src/components/Activation/releaseConfigs.ts
Normal file
82
web/src/components/Activation/releaseConfigs.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { compare } from 'semver';
|
||||
|
||||
export interface ReleaseStepConfig {
|
||||
id: 'timezone' | 'plugins';
|
||||
condition?: () => boolean | Promise<boolean>;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface ReleaseConfig {
|
||||
version: string;
|
||||
steps: ReleaseStepConfig[];
|
||||
minVersion?: string;
|
||||
}
|
||||
|
||||
const releaseConfigs: ReleaseConfig[] = [
|
||||
{
|
||||
version: '7.0.0',
|
||||
steps: [
|
||||
{
|
||||
id: 'timezone',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'plugins',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const getReleaseConfig = (
|
||||
fromVersion: string | undefined,
|
||||
toVersion: string
|
||||
): ReleaseConfig | null => {
|
||||
if (!fromVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const applicableConfigs = releaseConfigs.filter((config) => {
|
||||
try {
|
||||
const isTargetVersion = compare(toVersion, config.version) >= 0;
|
||||
const isAfterMinVersion = !config.minVersion || compare(fromVersion, config.minVersion) >= 0;
|
||||
|
||||
return isTargetVersion && isAfterMinVersion;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (applicableConfigs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
applicableConfigs.sort((a, b) => compare(b.version, a.version));
|
||||
return applicableConfigs[0];
|
||||
};
|
||||
|
||||
export const getUpgradeSteps = async (
|
||||
fromVersion: string | undefined,
|
||||
toVersion: string
|
||||
): Promise<ReleaseStepConfig[]> => {
|
||||
const config = getReleaseConfig(fromVersion, toVersion);
|
||||
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const steps: ReleaseStepConfig[] = [];
|
||||
|
||||
for (const step of config.steps) {
|
||||
if (step.condition) {
|
||||
const shouldShow = await step.condition();
|
||||
if (shouldShow) {
|
||||
steps.push(step);
|
||||
}
|
||||
} else {
|
||||
steps.push(step);
|
||||
}
|
||||
}
|
||||
|
||||
return steps;
|
||||
};
|
||||
@@ -26,8 +26,10 @@ export const useActivationCodeModalStore = defineStore('activationCodeModal', ()
|
||||
* 4. it's not been explicitly hidden (isHidden === null)
|
||||
*
|
||||
* Shows for:
|
||||
* - Fresh installs with activation code (timezone → activation flow)
|
||||
* - Fresh installs without activation code (timezone only)
|
||||
* - Fresh installs with activation code (timezone → plugins → activation flow)
|
||||
* - Fresh installs without activation code (timezone → plugins)
|
||||
*
|
||||
* Note: Upgrade onboarding visibility is checked separately in the modal via upgradeOnboardingStore
|
||||
*/
|
||||
const isVisible = computed<boolean>(() => {
|
||||
if (isHidden.value === false) {
|
||||
|
||||
62
web/src/components/Activation/store/upgradeOnboarding.ts
Normal file
62
web/src/components/Activation/store/upgradeOnboarding.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import { useSessionStorage } from '@vueuse/core';
|
||||
|
||||
import type { ReleaseStepConfig } from '~/components/Activation/releaseConfigs';
|
||||
|
||||
import { getUpgradeSteps } from '~/components/Activation/releaseConfigs';
|
||||
import { UPGRADE_INFO_QUERY } from '~/components/Activation/upgradeInfo.query';
|
||||
|
||||
const UPGRADE_ONBOARDING_HIDDEN_KEY = 'upgrade-onboarding-hidden';
|
||||
|
||||
export const useUpgradeOnboardingStore = defineStore('upgradeOnboarding', () => {
|
||||
const { result: upgradeInfoResult, loading: upgradeInfoLoading } = useQuery(
|
||||
UPGRADE_INFO_QUERY,
|
||||
{},
|
||||
{ errorPolicy: 'all' }
|
||||
);
|
||||
|
||||
const isHidden = useSessionStorage<boolean>(UPGRADE_ONBOARDING_HIDDEN_KEY, false);
|
||||
|
||||
const isUpgrade = computed(() => upgradeInfoResult.value?.info?.versions?.upgrade?.isUpgrade ?? false);
|
||||
const previousVersion = computed(
|
||||
() => upgradeInfoResult.value?.info?.versions?.upgrade?.previousVersion
|
||||
);
|
||||
const currentVersion = computed(
|
||||
() => upgradeInfoResult.value?.info?.versions?.upgrade?.currentVersion
|
||||
);
|
||||
|
||||
const upgradeSteps = ref<ReleaseStepConfig[]>([]);
|
||||
|
||||
watch(
|
||||
[isUpgrade, previousVersion, currentVersion],
|
||||
async ([isUpgradeValue, prevVersion, currVersion]) => {
|
||||
if (isUpgradeValue && prevVersion && currVersion) {
|
||||
upgradeSteps.value = await getUpgradeSteps(prevVersion, currVersion);
|
||||
} else {
|
||||
upgradeSteps.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const shouldShowUpgradeOnboarding = computed(() => {
|
||||
return !isHidden.value && isUpgrade.value && upgradeSteps.value.length > 0;
|
||||
});
|
||||
|
||||
const setIsHidden = (value: boolean) => {
|
||||
isHidden.value = value;
|
||||
};
|
||||
|
||||
return {
|
||||
loading: computed(() => upgradeInfoLoading.value),
|
||||
isUpgrade,
|
||||
previousVersion,
|
||||
currentVersion,
|
||||
upgradeSteps,
|
||||
shouldShowUpgradeOnboarding,
|
||||
isHidden,
|
||||
setIsHidden,
|
||||
};
|
||||
});
|
||||
17
web/src/components/Activation/upgradeInfo.query.ts
Normal file
17
web/src/components/Activation/upgradeInfo.query.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { graphql } from '~/composables/gql';
|
||||
|
||||
export const UPGRADE_INFO_QUERY = graphql(/* GraphQL */ `
|
||||
query UpgradeInfo {
|
||||
info {
|
||||
id
|
||||
versions {
|
||||
id
|
||||
upgrade {
|
||||
isUpgrade
|
||||
previousVersion
|
||||
currentVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -50,8 +50,8 @@ watch([form], () => {
|
||||
});
|
||||
|
||||
const items = [
|
||||
{ value: 'white', label: 'Light' },
|
||||
{ value: 'black', label: 'Dark' },
|
||||
{ value: 'white', label: 'White' },
|
||||
{ value: 'black', label: 'Black' },
|
||||
{ value: 'azure', label: 'Azure' },
|
||||
{ value: 'gray', label: 'Gray' },
|
||||
];
|
||||
|
||||
@@ -13,8 +13,8 @@ const { theme } = storeToRefs(themeStore);
|
||||
|
||||
// Available theme options
|
||||
const items = [
|
||||
{ value: 'white', label: 'Light' },
|
||||
{ value: 'black', label: 'Dark' },
|
||||
{ value: 'white', label: 'White' },
|
||||
{ value: 'black', label: 'Black' },
|
||||
{ value: 'azure', label: 'Azure' },
|
||||
{ value: 'gray', label: 'Gray' },
|
||||
];
|
||||
|
||||
@@ -18,6 +18,7 @@ type Documents = {
|
||||
"\n query PublicWelcomeData {\n publicPartnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n isInitialSetup\n }\n": typeof types.PublicWelcomeDataDocument,
|
||||
"\n query ActivationCode {\n vars {\n regState\n }\n customization {\n activationCode {\n code\n partnerName\n serverName\n sysModel\n comment\n header\n headermetacolor\n background\n showBannerGradient\n theme\n }\n partnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n }\n }\n": typeof types.ActivationCodeDocument,
|
||||
"\n mutation UpdateSystemTime($input: UpdateSystemTimeInput!) {\n updateSystemTime(input: $input) {\n currentTime\n timeZone\n useNtp\n ntpServers\n }\n }\n": typeof types.UpdateSystemTimeDocument,
|
||||
"\n query UpgradeInfo {\n info {\n id\n versions {\n id\n upgrade {\n isUpgrade\n previousVersion\n currentVersion\n }\n }\n }\n }\n": typeof types.UpgradeInfoDocument,
|
||||
"\n query GetApiKeyCreationFormSchema {\n getApiKeyCreationFormSchema {\n id\n dataSchema\n uiSchema\n values\n }\n }\n": typeof types.GetApiKeyCreationFormSchemaDocument,
|
||||
"\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKey\n }\n }\n }\n": typeof types.CreateApiKeyDocument,
|
||||
"\n mutation UpdateApiKey($input: UpdateApiKeyInput!) {\n apiKey {\n update(input: $input) {\n ...ApiKey\n }\n }\n }\n": typeof types.UpdateApiKeyDocument,
|
||||
@@ -89,6 +90,7 @@ const documents: Documents = {
|
||||
"\n query PublicWelcomeData {\n publicPartnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n isInitialSetup\n }\n": types.PublicWelcomeDataDocument,
|
||||
"\n query ActivationCode {\n vars {\n regState\n }\n customization {\n activationCode {\n code\n partnerName\n serverName\n sysModel\n comment\n header\n headermetacolor\n background\n showBannerGradient\n theme\n }\n partnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n }\n }\n": types.ActivationCodeDocument,
|
||||
"\n mutation UpdateSystemTime($input: UpdateSystemTimeInput!) {\n updateSystemTime(input: $input) {\n currentTime\n timeZone\n useNtp\n ntpServers\n }\n }\n": types.UpdateSystemTimeDocument,
|
||||
"\n query UpgradeInfo {\n info {\n id\n versions {\n id\n upgrade {\n isUpgrade\n previousVersion\n currentVersion\n }\n }\n }\n }\n": types.UpgradeInfoDocument,
|
||||
"\n query GetApiKeyCreationFormSchema {\n getApiKeyCreationFormSchema {\n id\n dataSchema\n uiSchema\n values\n }\n }\n": types.GetApiKeyCreationFormSchemaDocument,
|
||||
"\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKey\n }\n }\n }\n": types.CreateApiKeyDocument,
|
||||
"\n mutation UpdateApiKey($input: UpdateApiKeyInput!) {\n apiKey {\n update(input: $input) {\n ...ApiKey\n }\n }\n }\n": types.UpdateApiKeyDocument,
|
||||
@@ -186,6 +188,10 @@ export function graphql(source: "\n query ActivationCode {\n vars {\n r
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation UpdateSystemTime($input: UpdateSystemTimeInput!) {\n updateSystemTime(input: $input) {\n currentTime\n timeZone\n useNtp\n ntpServers\n }\n }\n"): (typeof documents)["\n mutation UpdateSystemTime($input: UpdateSystemTimeInput!) {\n updateSystemTime(input: $input) {\n currentTime\n timeZone\n useNtp\n ntpServers\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query UpgradeInfo {\n info {\n id\n versions {\n id\n upgrade {\n isUpgrade\n previousVersion\n currentVersion\n }\n }\n }\n }\n"): (typeof documents)["\n query UpgradeInfo {\n info {\n id\n versions {\n id\n upgrade {\n isUpgrade\n previousVersion\n currentVersion\n }\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
@@ -1309,6 +1309,8 @@ export type InfoVersions = Node & {
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Software package versions */
|
||||
packages?: Maybe<PackageVersions>;
|
||||
/** OS upgrade information */
|
||||
upgrade: UpgradeInfo;
|
||||
};
|
||||
|
||||
export type InitiateFlashBackupInput = {
|
||||
@@ -2610,6 +2612,16 @@ export type UpdateSystemTimeInput = {
|
||||
useNtp?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
};
|
||||
|
||||
export type UpgradeInfo = {
|
||||
__typename?: 'UpgradeInfo';
|
||||
/** Current OS version */
|
||||
currentVersion?: Maybe<Scalars['String']['output']>;
|
||||
/** Whether the OS version has changed since last boot */
|
||||
isUpgrade: Scalars['Boolean']['output'];
|
||||
/** Previous OS version before upgrade */
|
||||
previousVersion?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Uptime = {
|
||||
__typename?: 'Uptime';
|
||||
timestamp?: Maybe<Scalars['String']['output']>;
|
||||
@@ -2923,6 +2935,11 @@ export type UpdateSystemTimeMutationVariables = Exact<{
|
||||
|
||||
export type UpdateSystemTimeMutation = { __typename?: 'Mutation', updateSystemTime: { __typename?: 'SystemTime', currentTime: string, timeZone: string, useNtp: boolean, ntpServers: Array<string> } };
|
||||
|
||||
export type UpgradeInfoQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type UpgradeInfoQuery = { __typename?: 'Query', info: { __typename?: 'Info', id: string, versions: { __typename?: 'InfoVersions', id: string, upgrade: { __typename?: 'UpgradeInfo', isUpgrade: boolean, previousVersion?: string | null, currentVersion?: string | null } } } };
|
||||
|
||||
export type GetApiKeyCreationFormSchemaQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
@@ -3372,6 +3389,7 @@ export const PartnerInfoDocument = {"kind":"Document","definitions":[{"kind":"Op
|
||||
export const PublicWelcomeDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PublicWelcomeData"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publicPartnerInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasPartnerLogo"}},{"kind":"Field","name":{"kind":"Name","value":"partnerName"}},{"kind":"Field","name":{"kind":"Name","value":"partnerUrl"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isInitialSetup"}}]}}]} as unknown as DocumentNode<PublicWelcomeDataQuery, PublicWelcomeDataQueryVariables>;
|
||||
export const ActivationCodeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ActivationCode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"vars"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"regState"}}]}},{"kind":"Field","name":{"kind":"Name","value":"customization"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activationCode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"partnerName"}},{"kind":"Field","name":{"kind":"Name","value":"serverName"}},{"kind":"Field","name":{"kind":"Name","value":"sysModel"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}},{"kind":"Field","name":{"kind":"Name","value":"header"}},{"kind":"Field","name":{"kind":"Name","value":"headermetacolor"}},{"kind":"Field","name":{"kind":"Name","value":"background"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerGradient"}},{"kind":"Field","name":{"kind":"Name","value":"theme"}}]}},{"kind":"Field","name":{"kind":"Name","value":"partnerInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasPartnerLogo"}},{"kind":"Field","name":{"kind":"Name","value":"partnerName"}},{"kind":"Field","name":{"kind":"Name","value":"partnerUrl"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoUrl"}}]}}]}}]}}]} as unknown as DocumentNode<ActivationCodeQuery, ActivationCodeQueryVariables>;
|
||||
export const UpdateSystemTimeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateSystemTime"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateSystemTimeInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateSystemTime"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentTime"}},{"kind":"Field","name":{"kind":"Name","value":"timeZone"}},{"kind":"Field","name":{"kind":"Name","value":"useNtp"}},{"kind":"Field","name":{"kind":"Name","value":"ntpServers"}}]}}]}}]} as unknown as DocumentNode<UpdateSystemTimeMutation, UpdateSystemTimeMutationVariables>;
|
||||
export const UpgradeInfoDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"UpgradeInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"versions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"upgrade"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"isUpgrade"}},{"kind":"Field","name":{"kind":"Name","value":"previousVersion"}},{"kind":"Field","name":{"kind":"Name","value":"currentVersion"}}]}}]}}]}}]}}]} as unknown as DocumentNode<UpgradeInfoQuery, UpgradeInfoQueryVariables>;
|
||||
export const GetApiKeyCreationFormSchemaDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetApiKeyCreationFormSchema"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getApiKeyCreationFormSchema"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSchema"}},{"kind":"Field","name":{"kind":"Name","value":"uiSchema"}},{"kind":"Field","name":{"kind":"Name","value":"values"}}]}}]}}]} as unknown as DocumentNode<GetApiKeyCreationFormSchemaQuery, GetApiKeyCreationFormSchemaQueryVariables>;
|
||||
export const CreateApiKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateApiKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateApiKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApiKey"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKey"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<CreateApiKeyMutation, CreateApiKeyMutationVariables>;
|
||||
export const UpdateApiKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateApiKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateApiKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"update"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApiKey"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKey"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<UpdateApiKeyMutation, UpdateApiKeyMutationVariables>;
|
||||
|
||||
Reference in New Issue
Block a user