fix: update @unraid/shared-callbacks to version 3.0.0 (#1831)

…on and pnpm-lock.yaml

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a standalone redirect page that shows "Redirecting..." and
navigates automatically.

* **Improvements**
* Redirect preserves hash callback data, validates targets, and logs the
computed redirect.
  * Purchase callback origin changed to a different account host.
* Date/time formatting now tolerates missing or empty server formats
with safe fallbacks.
  * Redirect page included in backup/restore.

* **Tests**
  * Added tests covering date/time formatting fallbacks.

* **Chores**
  * Dependency @unraid/shared-callbacks upgraded.
  * Removed multiple demo/debug pages and related test UIs.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Eli Bosley
2025-12-15 16:20:18 -05:00
committed by GitHub
parent d6e29395c8
commit 73b2ce360c
22 changed files with 276 additions and 1439 deletions

View File

@@ -206,6 +206,7 @@ FILES_TO_BACKUP=(
"/usr/local/emhttp/plugins/dynamix.my.servers/include/reboot-details.php" "/usr/local/emhttp/plugins/dynamix.my.servers/include/reboot-details.php"
"/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php" "/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php"
"/usr/local/emhttp/update.htm" "/usr/local/emhttp/update.htm"
"/usr/local/emhttp/redirect.htm"
"/usr/local/emhttp/logging.htm" "/usr/local/emhttp/logging.htm"
"/etc/nginx/nginx.conf" "/etc/nginx/nginx.conf"
"/etc/rc.d/rc.nginx" "/etc/rc.d/rc.nginx"
@@ -349,6 +350,7 @@ exit 0
"/usr/local/emhttp/plugins/dynamix.my.servers/include/reboot-details.php" "/usr/local/emhttp/plugins/dynamix.my.servers/include/reboot-details.php"
"/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php" "/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php"
"/usr/local/emhttp/update.htm" "/usr/local/emhttp/update.htm"
"/usr/local/emhttp/redirect.htm"
"/usr/local/emhttp/logging.htm" "/usr/local/emhttp/logging.htm"
"/etc/nginx/nginx.conf" "/etc/nginx/nginx.conf"
"/etc/rc.d/rc.nginx" "/etc/rc.d/rc.nginx"

View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Redirect Page</title>
</head>
<body>
<div id="text" style="text-align: center; margin-top: calc(100vh - 75%); display: none; font-family: sans-serif;">
<h1>Redirecting...</h1>
<h2><a id="redirectButton" href="/Main">Click here if you are not redirected automatically</a></h2>
</div>
<script>
(function () {
function parseRedirectTarget(target) {
if (target && target !== '/') {
// Parse target and ensure it is a bare path with no query parameters.
// This keeps us on the same origin and avoids arbitrary redirects.
try {
const url = new URL(target, window.location.origin);
return url.pathname || '/Main';
} catch (_e) {
// If the target is malformed, fall back safely.
return '/Main';
}
}
return '/Main';
}
function getRedirectUrl() {
const search = new URLSearchParams(window.location.search);
const rawHash = window.location.hash || '';
const hashString = rawHash.charAt(0) === '#' ? rawHash.substring(1) : rawHash;
let hashData = '';
if (hashString.startsWith('data=')) {
hashData = hashString.slice('data='.length);
}
const targetRoute = parseRedirectTarget(search.get('target'));
const baseUrl = `${window.location.origin}${targetRoute}`;
// If the incoming URL already has a hash-based data payload, preserve it exactly.
if (hashData) {
return `${baseUrl}#data=${hashData}`;
}
// Fallback: accept legacy ?data= input and convert it to hash-based data.
const queryData = search.get('data');
if (queryData) {
const encoded = encodeURIComponent(queryData);
return `${baseUrl}#data=${encoded}`;
}
return baseUrl;
}
function showText() {
const textEl = document.getElementById('text');
if (textEl) {
textEl.style.display = 'block';
}
}
function startRedirect() {
setTimeout(showText, 750);
const redirectUrl = getRedirectUrl();
console.log('[redirect.htm] redirecting to:', redirectUrl);
const link = document.getElementById('redirectButton');
if (link) {
link.setAttribute('href', redirectUrl);
}
window.location.href = redirectUrl;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startRedirect);
} else {
startRedirect();
}
})();
</script>
</body>
</html>

13
pnpm-lock.yaml generated
View File

@@ -1086,8 +1086,8 @@ importers:
specifier: 4.0.0-alpha.0 specifier: 4.0.0-alpha.0
version: 4.0.0-alpha.0(@babel/parser@7.28.4)(@netlify/blobs@9.1.2)(change-case@5.4.4)(db0@0.3.2)(embla-carousel@8.6.0)(focus-trap@7.6.5)(ioredis@5.7.0)(jwt-decode@4.0.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue-router@4.5.1(vue@3.5.20(typescript@5.9.2)))(vue@3.5.20(typescript@5.9.2))(zod@3.25.76) version: 4.0.0-alpha.0(@babel/parser@7.28.4)(@netlify/blobs@9.1.2)(change-case@5.4.4)(db0@0.3.2)(embla-carousel@8.6.0)(focus-trap@7.6.5)(ioredis@5.7.0)(jwt-decode@4.0.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue-router@4.5.1(vue@3.5.20(typescript@5.9.2)))(vue@3.5.20(typescript@5.9.2))(zod@3.25.76)
'@unraid/shared-callbacks': '@unraid/shared-callbacks':
specifier: 1.1.1 specifier: 3.0.0
version: 1.1.1(@vueuse/core@13.8.0(vue@3.5.20(typescript@5.9.2))) version: 3.0.0
'@unraid/ui': '@unraid/ui':
specifier: link:../unraid-ui specifier: link:../unraid-ui
version: link:../unraid-ui version: link:../unraid-ui
@@ -5096,10 +5096,8 @@ packages:
cpu: [x64, arm64] cpu: [x64, arm64]
os: [linux, darwin] os: [linux, darwin]
'@unraid/shared-callbacks@1.1.1': '@unraid/shared-callbacks@3.0.0':
resolution: {integrity: sha512-14x5HFBOIVfUpQFAAhcRqIvj3AIsOyx90BdShXtddW55kiVtg+dDfsnlzExSYWhb35C6gYKZ0Sm9ZhF/YamGzg==} resolution: {integrity: sha512-O4AN5nsmnwUQ1utYhG2wS9L2NAFn3eOg5YHKq9h9EUa3n8xQeUOzeM6UV2xBg9YJGuF3wQsaEpfj1GyX/MIAGw==}
peerDependencies:
'@vueuse/core': ^10.9.0 || ^13.0.0
'@unraid/tailwind-rem-to-rem@2.0.0': '@unraid/tailwind-rem-to-rem@2.0.0':
resolution: {integrity: sha512-zccpQx5fvEBkAB0JkRwwtyRrT9l26LsjkozLy44LGv0NdZGaxgscniIqJRM+OQj5pSpsWDzExebAtUKdE98Flg==} resolution: {integrity: sha512-zccpQx5fvEBkAB0JkRwwtyRrT9l26LsjkozLy44LGv0NdZGaxgscniIqJRM+OQj5pSpsWDzExebAtUKdE98Flg==}
@@ -17140,9 +17138,8 @@ snapshots:
- encoding - encoding
- supports-color - supports-color
'@unraid/shared-callbacks@1.1.1(@vueuse/core@13.8.0(vue@3.5.20(typescript@5.9.2)))': '@unraid/shared-callbacks@3.0.0':
dependencies: dependencies:
'@vueuse/core': 13.8.0(vue@3.5.20(typescript@5.9.2))
crypto-js: 4.2.0 crypto-js: 4.2.0
'@unraid/tailwind-rem-to-rem@2.0.0(tailwindcss@4.1.12)': '@unraid/tailwind-rem-to-rem@2.0.0(tailwindcss@4.1.12)':

View File

@@ -9,7 +9,7 @@ import { BrandButton } from '@unraid/ui';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ServerStateDataAction, ServerStateDataActionType } from '~/types/server'; import type { ServerStateDataAction } from '~/types/server';
import KeyActions from '~/components/KeyActions.vue'; import KeyActions from '~/components/KeyActions.vue';
import { createTestI18n } from '../utils/i18n'; import { createTestI18n } from '../utils/i18n';
@@ -34,7 +34,7 @@ describe('KeyActions', () => {
it('renders buttons from props when actions prop is provided', () => { it('renders buttons from props when actions prop is provided', () => {
const actions: ServerStateDataAction[] = [ const actions: ServerStateDataAction[] = [
{ name: 'purchase' as ServerStateDataActionType, text: 'Custom Action 1', click: vi.fn() }, { name: 'purchase', text: 'Custom Action 1', click: vi.fn() },
]; ];
const wrapper = mount(KeyActions, { const wrapper = mount(KeyActions, {
@@ -68,9 +68,7 @@ describe('KeyActions', () => {
it('calls action click handler when button is clicked', async () => { it('calls action click handler when button is clicked', async () => {
const click = vi.fn(); const click = vi.fn();
const actions: ServerStateDataAction[] = [ const actions: ServerStateDataAction[] = [{ name: 'purchase', text: 'Clickable Action', click }];
{ name: 'purchase' as ServerStateDataActionType, text: 'Clickable Action', click },
];
const wrapper = mount(KeyActions, { const wrapper = mount(KeyActions, {
props: { props: {
@@ -89,7 +87,7 @@ describe('KeyActions', () => {
const click = vi.fn(); const click = vi.fn();
const actions: ServerStateDataAction[] = [ const actions: ServerStateDataAction[] = [
{ {
name: 'purchase' as ServerStateDataActionType, name: 'purchase',
text: 'Disabled Action', text: 'Disabled Action',
disabled: true, disabled: true,
click, click,
@@ -111,9 +109,9 @@ describe('KeyActions', () => {
it('filters actions using filterBy prop', () => { it('filters actions using filterBy prop', () => {
const actions: ServerStateDataAction[] = [ const actions: ServerStateDataAction[] = [
{ name: 'purchase' as ServerStateDataActionType, text: 'Action 1', click: vi.fn() }, { name: 'purchase', text: 'Action 1', click: vi.fn() },
{ name: 'redeem' as ServerStateDataActionType, text: 'Action 2', click: vi.fn() }, { name: 'redeem', text: 'Action 2', click: vi.fn() },
{ name: 'upgrade' as ServerStateDataActionType, text: 'Action 3', click: vi.fn() }, { name: 'upgrade', text: 'Action 3', click: vi.fn() },
]; ];
const wrapper = mount(KeyActions, { const wrapper = mount(KeyActions, {
@@ -135,9 +133,9 @@ describe('KeyActions', () => {
it('filters out actions using filterOut prop', () => { it('filters out actions using filterOut prop', () => {
const actions: ServerStateDataAction[] = [ const actions: ServerStateDataAction[] = [
{ name: 'purchase' as ServerStateDataActionType, text: 'Action 1', click: vi.fn() }, { name: 'purchase', text: 'Action 1', click: vi.fn() },
{ name: 'redeem' as ServerStateDataActionType, text: 'Action 2', click: vi.fn() }, { name: 'redeem', text: 'Action 2', click: vi.fn() },
{ name: 'upgrade' as ServerStateDataActionType, text: 'Action 3', click: vi.fn() }, { name: 'upgrade', text: 'Action 3', click: vi.fn() },
]; ];
const wrapper = mount(KeyActions, { const wrapper = mount(KeyActions, {
@@ -158,9 +156,7 @@ describe('KeyActions', () => {
}); });
it('applies maxWidth styling when maxWidth prop is true', () => { it('applies maxWidth styling when maxWidth prop is true', () => {
const actions: ServerStateDataAction[] = [ const actions: ServerStateDataAction[] = [{ name: 'purchase', text: 'Action 1', click: vi.fn() }];
{ name: 'purchase' as ServerStateDataActionType, text: 'Action 1', click: vi.fn() },
];
const wrapper = mount(KeyActions, { const wrapper = mount(KeyActions, {
props: { props: {
@@ -180,7 +176,7 @@ describe('KeyActions', () => {
it('passes all required props to BrandButton component', () => { it('passes all required props to BrandButton component', () => {
const actions: ServerStateDataAction[] = [ const actions: ServerStateDataAction[] = [
{ {
name: 'purchase' as ServerStateDataActionType, name: 'purchase',
text: 'Test Action', text: 'Test Action',
title: 'Action Title', title: 'Action Title',
href: '/test-link', href: '/test-link',

View File

@@ -0,0 +1,58 @@
import { defineComponent } from 'vue';
import { mount } from '@vue/test-utils';
import dayjs from 'dayjs';
import { describe, expect, it } from 'vitest';
import type { ServerDateTimeFormat } from '~/types/server';
import useDateTimeHelper from '~/composables/dateTime';
import { testTranslate } from '../utils/i18n';
const formatDateWithComponent = (
dateTimeFormat: ServerDateTimeFormat | undefined,
hideMinutesSeconds: boolean,
providedDateTime: number
) => {
const wrapper = mount(
defineComponent({
setup() {
const { outputDateTimeFormatted } = useDateTimeHelper(
dateTimeFormat,
testTranslate,
hideMinutesSeconds,
providedDateTime
);
return { outputDateTimeFormatted };
},
template: '<div />',
})
);
const output = (wrapper.vm as unknown as { outputDateTimeFormatted: string | { value: string } })
.outputDateTimeFormatted;
return typeof output === 'string' ? output : output.value;
};
describe('useDateTimeHelper', () => {
it('falls back to default date format when server format is empty', () => {
const timestamp = new Date(2025, 0, 2, 3, 4, 5).getTime();
const formatted = formatDateWithComponent({ date: '', time: '' }, true, timestamp);
expect(formatted).toBe(dayjs(timestamp).format('dddd, MMMM D, YYYY'));
});
it('falls back to default date format when server format is unknown', () => {
const timestamp = new Date(2025, 0, 2, 3, 4, 5).getTime();
const formatted = formatDateWithComponent({ date: '%Q', time: '%Q' }, true, timestamp);
expect(formatted).toBe(dayjs(timestamp).format('dddd, MMMM D, YYYY'));
});
it('falls back to default time format when server time format is unknown', () => {
const timestamp = new Date(2025, 0, 2, 3, 4, 5).getTime();
const formatted = formatDateWithComponent({ date: '%c', time: '%Q' }, false, timestamp);
expect(formatted).toBe(dayjs(timestamp).format('ddd, D MMMM YYYY hh:mma'));
});
});

View File

@@ -161,37 +161,45 @@ const getStore = () => {
}, },
serverPurchasePayload: { serverPurchasePayload: {
get: () => ({ get: () => ({
apiVersion: store.apiVersion,
connectPluginVersion: store.connectPluginVersion,
deviceCount: store.deviceCount,
email: store.email,
guid: store.guid,
keyTypeForPurchase: store.state === 'PLUS' ? 'Plus' : store.state === 'PRO' ? 'Pro' : 'Trial',
locale: store.locale,
osVersion: store.osVersion,
osVersionBranch: store.osVersionBranch,
registered: store.registered ?? false,
regExp: store.regExp,
regTy: store.regTy,
regUpdatesExpired: store.regUpdatesExpired,
state: store.state,
site: store.site,
}),
},
serverAccountPayload: {
get: () => ({
apiVersion: store.apiVersion,
caseModel: store.caseModel,
connectPluginVersion: store.connectPluginVersion,
deviceCount: store.deviceCount,
description: store.description, description: store.description,
deviceCount: store.deviceCount,
expireTime: store.expireTime,
flashProduct: store.flashProduct, flashProduct: store.flashProduct,
flashVendor: store.flashVendor,
guid: store.guid, guid: store.guid,
locale: store.locale,
name: store.name, name: store.name,
osVersion: store.osVersion, osVersion: store.osVersion,
osVersionBranch: store.osVersionBranch, osVersionBranch: store.osVersionBranch,
registered: store.registered ?? false, registered: store.registered ?? false,
regExp: store.regExp,
regGen: store.regGen,
regGuid: store.regGuid,
regTy: store.regTy, regTy: store.regTy,
regUpdatesExpired: store.regUpdatesExpired,
state: store.state,
wanFQDN: store.wanFQDN,
}),
},
serverAccountPayload: {
get: () => ({
deviceCount: store.deviceCount,
description: store.description,
expireTime: store.expireTime,
flashProduct: store.flashProduct,
flashVendor: store.flashVendor,
guid: store.guid,
keyfile: store.keyfile,
locale: store.locale,
name: store.name,
osVersion: store.osVersion,
osVersionBranch: store.osVersionBranch,
registered: store.registered ?? false,
regExp: store.regExp,
regGen: store.regGen,
regGuid: store.regGuid,
regTy: store.regTy,
regUpdatesExpired: store.regUpdatesExpired,
state: store.state, state: store.state,
wanFQDN: store.wanFQDN, wanFQDN: store.wanFQDN,
}), }),
@@ -549,49 +557,65 @@ describe('useServerStore', () => {
const store = getStore(); const store = getStore();
store.setServer({ store.setServer({
apiVersion: '1.0.0',
connectPluginVersion: '2.0.0',
deviceCount: 6, deviceCount: 6,
email: 'test@example.com', description: 'Test Server',
expireTime: 123,
flashProduct: 'TestFlash',
flashVendor: 'TestVendor',
guid: '123456', guid: '123456',
inIframe: false,
locale: 'en-US', locale: 'en-US',
name: 'TestServer',
osVersion: '6.10.3', osVersion: '6.10.3',
osVersionBranch: 'stable', osVersionBranch: 'stable',
registered: true, registered: true,
regGen: 7,
regGuid: 'reg-guid-1',
regExp: 1234567890, regExp: 1234567890,
regTy: 'Plus', regTy: 'Plus',
state: 'PLUS' as ServerState, state: 'PLUS' as ServerState,
site: 'local', wanFQDN: 'test.myunraid.net',
}); });
const payload = store.serverPurchasePayload; const payload = store.serverPurchasePayload;
expect(payload.apiVersion).toBe('1.0.0'); expect(payload.description).toBe('Test Server');
expect(payload.connectPluginVersion).toBe('2.0.0');
expect(payload.deviceCount).toBe(6); expect(payload.deviceCount).toBe(6);
expect(payload.email).toBe('test@example.com'); expect(payload.expireTime).toBe(123);
expect(payload.flashProduct).toBe('TestFlash');
expect(payload.flashVendor).toBe('TestVendor');
expect(payload.guid).toBe('123456'); expect(payload.guid).toBe('123456');
expect(payload.keyTypeForPurchase).toBe('Plus');
expect(payload.locale).toBe('en-US'); expect(payload.locale).toBe('en-US');
expect(payload.name).toBe('TestServer');
expect(payload.osVersion).toBe('6.10.3'); expect(payload.osVersion).toBe('6.10.3');
expect(payload.osVersionBranch).toBe('stable');
expect(payload.registered).toBe(true); expect(payload.registered).toBe(true);
expect(payload.regExp).toBe(1234567890);
expect(payload.regGen).toBe(7);
expect(payload.regGuid).toBe('reg-guid-1');
expect(payload.regTy).toBe('Plus');
expect(payload.state).toBe('PLUS');
expect(payload.wanFQDN).toBe('test.myunraid.net');
}); });
it('should create serverAccountPayload correctly', () => { it('should create serverAccountPayload correctly', () => {
const store = getStore(); const store = getStore();
store.setServer({ store.setServer({
apiVersion: '1.0.0',
caseModel: 'TestCase',
connectPluginVersion: '2.0.0',
deviceCount: 6, deviceCount: 6,
description: 'Test Server', description: 'Test Server',
expireTime: 123,
flashProduct: 'TestFlash', flashProduct: 'TestFlash',
flashVendor: 'TestVendor',
guid: '123456', guid: '123456',
keyfile: '/boot/config/Plus.key',
locale: 'en-US',
name: 'TestServer', name: 'TestServer',
osVersion: '6.10.3', osVersion: '6.10.3',
osVersionBranch: 'stable',
registered: true, registered: true,
regExp: 1234567890,
regGen: 7,
regGuid: 'reg-guid-1',
regTy: 'Plus', regTy: 'Plus',
state: 'PLUS' as ServerState, state: 'PLUS' as ServerState,
wanFQDN: 'test.myunraid.net', wanFQDN: 'test.myunraid.net',
@@ -599,16 +623,23 @@ describe('useServerStore', () => {
const payload = store.serverAccountPayload; const payload = store.serverAccountPayload;
expect(payload.apiVersion).toBe('1.0.0'); expect(payload.deviceCount).toBe(6);
expect(payload.caseModel).toBe('TestCase');
expect(payload.connectPluginVersion).toBe('2.0.0');
expect(payload.description).toBe('Test Server'); expect(payload.description).toBe('Test Server');
expect(payload.expireTime).toBe(123);
expect(payload.flashProduct).toBe('TestFlash'); expect(payload.flashProduct).toBe('TestFlash');
expect(payload.flashVendor).toBe('TestVendor');
expect(payload.guid).toBe('123456'); expect(payload.guid).toBe('123456');
expect(payload.keyfile).toBe('/boot/config/Plus.key');
expect(payload.locale).toBe('en-US');
expect(payload.name).toBe('TestServer'); expect(payload.name).toBe('TestServer');
expect(payload.osVersion).toBe('6.10.3'); expect(payload.osVersion).toBe('6.10.3');
expect(payload.osVersionBranch).toBe('stable');
expect(payload.registered).toBe(true); expect(payload.registered).toBe(true);
expect(payload.regExp).toBe(1234567890);
expect(payload.regGen).toBe(7);
expect(payload.regGuid).toBe('reg-guid-1');
expect(payload.regTy).toBe('Plus'); expect(payload.regTy).toBe('Plus');
expect(payload.regUpdatesExpired).toBe(true);
expect(payload.state).toBe('PLUS'); expect(payload.state).toBe('PLUS');
expect(payload.wanFQDN).toBe('test.myunraid.net'); expect(payload.wanFQDN).toBe('test.myunraid.net');
}); });

View File

@@ -109,7 +109,7 @@
"@jsonforms/vue-vanilla": "3.6.0", "@jsonforms/vue-vanilla": "3.6.0",
"@jsonforms/vue-vuetify": "3.6.0", "@jsonforms/vue-vuetify": "3.6.0",
"@nuxt/ui": "4.0.0-alpha.0", "@nuxt/ui": "4.0.0-alpha.0",
"@unraid/shared-callbacks": "1.1.1", "@unraid/shared-callbacks": "3.0.0",
"@unraid/ui": "link:../unraid-ui", "@unraid/ui": "link:../unraid-ui",
"@vue/apollo-composable": "4.2.2", "@vue/apollo-composable": "4.2.2",
"@vueuse/components": "13.8.0", "@vueuse/components": "13.8.0",

View File

@@ -101,15 +101,23 @@ const useDateTimeHelper = (
): DateFormatOption | TimeFormatOption | undefined => ): DateFormatOption | TimeFormatOption | undefined =>
formats.find((formatOption) => formatOption.format === selectedFormat); formats.find((formatOption) => formatOption.format === selectedFormat);
const dateFormat = findMatchingFormat(format?.date ?? dateFormatOptions[0].format, dateFormatOptions); const defaultTimeFormat = timeFormatOptions[0];
const fallbackDateDisplayFormat = 'dddd, MMMM D, YYYY';
let displayFormat = `${dateFormat?.display}`; const dateFormatFromServer = (format?.date ?? '').trim();
const dateFormat = dateFormatFromServer
? (findMatchingFormat(dateFormatFromServer, dateFormatOptions) as DateFormatOption | undefined)
: undefined;
let displayFormat = dateFormat?.display ?? fallbackDateDisplayFormat;
if (!hideMinutesSeconds) { if (!hideMinutesSeconds) {
const timeFormat = findMatchingFormat( const timeFormatFromServer = (format?.time ?? '').trim();
format?.time ?? timeFormatOptions[0].format, const timeFormat = timeFormatFromServer
timeFormatOptions ? (findMatchingFormat(timeFormatFromServer, timeFormatOptions) as TimeFormatOption | undefined)
); : undefined;
displayFormat = `${displayFormat} ${timeFormat?.display}`;
const timeDisplay = timeFormat?.display ?? defaultTimeFormat.display;
displayFormat = `${displayFormat} ${timeDisplay}`;
} }
const formatDate = (date: number): string => dayjs(date).format(displayFormat); const formatDate = (date: number): string => dayjs(date).format(displayFormat);

View File

@@ -16,7 +16,7 @@ const CONNECT_DASHBOARD = new URL(import.meta.env.VITE_CONNECT ?? 'https://conne
const CONNECT_FORUMS = new URL('/forum/94-connect-plugin-support/', FORUMS); const CONNECT_FORUMS = new URL('/forum/94-connect-plugin-support/', FORUMS);
const CONTACT = new URL('/contact', UNRAID_NET); const CONTACT = new URL('/contact', UNRAID_NET);
const DISCORD = new URL('https://discord.unraid.net'); const DISCORD = new URL('https://discord.unraid.net');
const PURCHASE_CALLBACK = new URL('/c', UNRAID_NET); const PURCHASE_CALLBACK = new URL('c', ACCOUNT);
const UNRAID_NET_SUPPORT = new URL('/support', UNRAID_NET); const UNRAID_NET_SUPPORT = new URL('/support', UNRAID_NET);
const WEBGUI_GRAPHQL = '/graphql'; const WEBGUI_GRAPHQL = '/graphql';

View File

@@ -1,7 +0,0 @@
<script setup lang="ts">
import ApiKeyManager from '~/components/ApiKey/ApiKeyManager.vue';
</script>
<template>
<ApiKeyManager />
</template>

View File

@@ -1,116 +0,0 @@
<script setup lang="ts">
import { onBeforeMount } from 'vue';
import { storeToRefs } from 'pinia';
import UpdateOsChangelogModal from '~/components/UpdateOs/ChangelogModal.vue';
import { useUpdateOsStore } from '~/store/updateOs';
const updateOsStore = useUpdateOsStore();
const { changelogModalVisible } = storeToRefs(updateOsStore);
onBeforeMount(() => {
// Register custom elements if needed for ColorSwitcherCe
});
async function showChangelogModalFromReleasesEndpoint() {
const response = await fetch('https://releases.unraid.net/os?branch=stable&current_version=6.12.3');
const data = await response.json();
updateOsStore.setReleaseForUpdate(data);
}
function showChangelogModalWithTestData() {
updateOsStore.setReleaseForUpdate({
version: '6.12.3',
date: '2023-07-15',
changelog:
'https://raw.githubusercontent.com/unraid/docs/main/docs/unraid-os/release-notes/6.12.3.md',
changelogPretty: 'https://docs.unraid.net/go/release-notes/6.12.3',
name: '6.12.3',
isEligible: true,
isNewer: true,
sha256: '1234567890',
});
}
function showChangelogWithoutPretty() {
updateOsStore.setReleaseForUpdate({
version: '6.12.3',
date: '2023-07-15',
changelog:
'https://raw.githubusercontent.com/unraid/docs/main/docs/unraid-os/release-notes/6.12.3.md',
changelogPretty: '',
name: '6.12.3',
isEligible: true,
isNewer: true,
sha256: '1234567890',
});
}
function showChangelogBrokenParse() {
updateOsStore.setReleaseForUpdate({
version: '6.12.3',
date: '2023-07-15',
changelog: null,
changelogPretty: undefined, // intentionally broken
name: '6.12.3',
isEligible: true,
isNewer: true,
sha256: '1234567890',
});
}
function showChangelogFromLocalhost() {
updateOsStore.setReleaseForUpdate({
version: '6.12.3',
date: '2023-07-15',
changelog:
'https://raw.githubusercontent.com/unraid/docs/main/docs/unraid-os/release-notes/6.12.3.md',
changelogPretty: 'http://localhost:3000/unraid-os/release-notes/6.12.3',
name: '6.12.3',
isEligible: true,
isNewer: true,
sha256: '1234567890',
});
}
</script>
<template>
<div class="container mx-auto p-6">
<h1 class="mb-6 text-2xl font-bold">Changelog</h1>
<UpdateOsChangelogModal :open="changelogModalVisible" />
<div class="mb-6 flex flex-col gap-4">
<div class="flex max-w-md flex-col gap-4">
<button
class="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
@click="showChangelogModalFromReleasesEndpoint"
>
Test Changelog Modal (from releases endpoint)
</button>
<button
class="rounded bg-purple-500 px-4 py-2 text-white hover:bg-purple-600"
@click="showChangelogFromLocalhost"
>
Test Local Pretty Changelog (:3000)
</button>
<button
class="rounded bg-red-500 px-4 py-2 text-white hover:bg-red-600"
@click="showChangelogModalWithTestData"
>
Test Changelog Modal (with test data)
</button>
<button
class="rounded bg-green-500 px-4 py-2 text-white hover:bg-green-600"
@click="showChangelogWithoutPretty"
>
Test Without Pretty Changelog
</button>
<button
class="rounded bg-yellow-500 px-4 py-2 text-white hover:bg-yellow-600"
@click="showChangelogBrokenParse"
>
Test Broken Parse Changelog
</button>
</div>
</div>
</div>
</template>

View File

@@ -1,17 +0,0 @@
<script setup>
import { onMounted } from 'vue';
import RCloneConfig from '~/components/RClone/RCloneConfig.vue';
import RCloneOverview from '~/components/RClone/RCloneOverview.vue';
onMounted(() => {
document.cookie = 'unraid_session_cookie=mockusersession';
});
</script>
<template>
<div>
<RCloneOverview />
<RCloneConfig />
</div>
</template>

View File

@@ -1,200 +0,0 @@
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import UButton from '@nuxt/ui/components/Button.vue';
import { ExclamationTriangleIcon } from '@heroicons/vue/24/solid';
import { BrandButton, Toaster } from '@unraid/ui';
import { useDummyServerStore } from '~/_data/serverState';
import AES from 'crypto-js/aes';
import type { SendPayloads } from '@unraid/shared-callbacks';
import WelcomeModalCe from '~/components/Activation/WelcomeModal.standalone.vue';
import ConnectSettingsCe from '~/components/ConnectSettings/ConnectSettings.standalone.vue';
import DowngradeOsCe from '~/components/DowngradeOs.standalone.vue';
import HeaderOsVersionCe from '~/components/HeaderOsVersion.standalone.vue';
import LogViewerCe from '~/components/Logs/LogViewer.standalone.vue';
import ModalsCe from '~/components/Modals.standalone.vue';
import RegistrationCe from '~/components/Registration.standalone.vue';
import SsoButtonCe from '~/components/SsoButton.standalone.vue';
import UpdateOsCe from '~/components/UpdateOs.standalone.vue';
import UserProfileCe from '~/components/UserProfile.standalone.vue';
import { useThemeStore } from '~/store/theme';
const serverStore = useDummyServerStore();
const { serverState } = storeToRefs(serverStore);
onMounted(() => {
document.cookie = 'unraid_session_cookie=mockusersession';
});
const valueToMakeCallback = ref<SendPayloads | undefined>();
const callbackDestination = ref<string | undefined>('');
const createCallbackUrl = (payload: SendPayloads, sendType: string) => {
// params differs from callbackActions.send
console.debug('[callback.send]');
valueToMakeCallback.value = payload; // differs from callbackActions.send
const stringifiedData = JSON.stringify({
actions: [...payload],
sender: window.location.href,
type: sendType,
});
const encryptedMessage = AES.encrypt(stringifiedData, import.meta.env.VITE_CALLBACK_KEY).toString();
// build and go to url
const destinationUrl = new URL(window.location.href); // differs from callbackActions.send
destinationUrl.searchParams.set('data', encodeURI(encryptedMessage));
callbackDestination.value = destinationUrl.toString(); // differs from callbackActions.send
};
const variants = [
'fill',
'black',
'gray',
'outline',
'outline-black',
'outline-white',
'underline',
'underline-hover-red',
'white',
'none',
] as const;
onMounted(() => {
createCallbackUrl(
[
{
// keyUrl: 'https://keys.lime-technology.com/unraid/d26a033e3097c65ab0b4f742a7c02ce808c6e963/Starter.key', // assigned to guid 1111-1111-5GDB-123412341234, use to test EGUID after key install
keyUrl:
'https://keys.lime-technology.com/unraid/7f7c2ddff1c38f21ed174f5c5d9f97b7b4577344/Starter.key',
type: 'renew',
},
{
sha256: 'a7d1a42fc661f55ee45d36bbc49aac71aef045cc1d287b1e7f16be0ba485c9b6',
type: 'updateOs',
},
],
'forUpc'
);
});
const bannerImage = ref<string>('none');
const { theme } = storeToRefs(useThemeStore());
watch(
theme,
(newTheme) => {
if (newTheme.banner) {
bannerImage.value = `url(https://picsum.photos/1920/200?${Math.round(Math.random() * 100)})`;
} else {
bannerImage.value = 'none';
}
},
{ immediate: true }
);
</script>
<template>
<div class="bg-white text-black dark:bg-black dark:text-white">
<div class="mx-auto pb-12">
<div class="flex flex-col gap-6 p-6">
<h2 class="font-mono text-xl font-semibold">Vue Components</h2>
<h3 class="font-mono text-lg font-semibold">UserProfileCe</h3>
<header
class="bg-header-background-color flex items-center justify-between"
:style="{
backgroundImage: bannerImage,
backgroundSize: 'cover',
backgroundPosition: 'center',
}"
>
<div class="inline-flex flex-col items-start gap-4">
<HeaderOsVersionCe />
</div>
<UserProfileCe :server="serverState" />
</header>
<!-- <hr class="border-black dark:border-white"> -->
<h3 class="font-mono text-lg font-semibold">ConnectSettingsCe</h3>
<ConnectSettingsCe />
<hr class="border-muted" />
<!-- <h3 class="text-lg font-semibold font-mono">
AuthCe
</h3>
<AuthCe />
<hr class="border-black dark:border-white"> -->
<!-- <h3 class="text-lg font-semibold font-mono">
WanIpCheckCe
</h3>
<WanIpCheckCe php-wan-ip="47.184.85.45" />
<hr class="border-black dark:border-white"> -->
<h3 class="font-mono text-lg font-semibold">UpdateOsCe</h3>
<UpdateOsCe />
<hr class="border-muted" />
<h3 class="font-mono text-lg font-semibold">DowngraadeOsCe</h3>
<DowngradeOsCe :restore-release-date="'2022-10-10'" :restore-version="'6.11.2'" />
<hr class="border-muted" />
<h3 class="font-mono text-lg font-semibold">RegistrationCe</h3>
<RegistrationCe />
<hr class="border-muted" />
<h3 class="font-mono text-lg font-semibold">ModalsCe</h3>
<ModalsCe />
<hr class="border-muted" />
<h3 class="font-mono text-lg font-semibold">WelcomeModalCe</h3>
<WelcomeModalCe />
<hr class="border-muted" />
<h3 class="font-mono text-lg font-semibold">Test Callback Builder</h3>
<div class="flex flex-col justify-end gap-2">
<p>
Modify the <code>createCallbackUrl</code> param in <code>onMounted</code> to test a callback.
</p>
<code>
<pre>{{ valueToMakeCallback }}</pre>
</code>
<BrandButton v-if="callbackDestination" :href="callbackDestination" :external="true">
{{ 'Go to Callback URL' }}
</BrandButton>
<h4>Full URL Destination</h4>
<code>
<pre>{{ callbackDestination }}</pre>
</code>
</div>
<div>
<hr class="border-muted" />
<h2 class="font-mono text-xl font-semibold">Nuxt UI Button - Primary Color Test</h2>
<div class="flex items-center gap-4">
<UButton color="primary" variant="solid">Primary Solid</UButton>
<UButton color="primary" variant="outline">Primary Outline</UButton>
<UButton color="primary" variant="soft">Primary Soft</UButton>
<UButton color="primary" variant="ghost">Primary Ghost</UButton>
<UButton color="primary" variant="link">Primary Link</UButton>
</div>
</div>
<div class="bg-background">
<hr class="border-muted" />
<h2 class="font-mono text-xl font-semibold">Brand Button Component</h2>
<template v-for="variant in variants" :key="variant">
<BrandButton :variant="variant" type="button" size="14px" :icon="ExclamationTriangleIcon">{{
variant
}}</BrandButton>
</template>
</div>
<div class="bg-background">
<hr class="border-muted" />
<h2 class="font-mono text-xl font-semibold">SSO Button Component</h2>
<SsoButtonCe />
</div>
<div class="bg-background">
<hr class="border-muted" />
<h2 class="font-mono text-xl font-semibold">Log Viewer Component</h2>
<LogViewerCe />
</div>
</div>
</div>
<Toaster rich-colors close-button />
</div>
</template>

View File

@@ -1,14 +0,0 @@
import { gql } from '@apollo/client/core';
export const SERVER_INFO_QUERY = gql`
query serverInfo {
info {
os {
hostname
}
}
vars {
comment
}
}
`;

View File

@@ -1,494 +0,0 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useQuery } from '@vue/apollo-composable';
import { Button, Dialog, Input } from '@unraid/ui';
import { SERVER_INFO_QUERY } from '~/pages/login.query';
import SsoButtonCe from '~/components/SsoButton.standalone.vue';
const { t } = useI18n();
const { result } = useQuery(SERVER_INFO_QUERY);
const serverName = computed(() => result.value?.info?.os?.hostname || 'UNRAID');
const serverComment = computed(() => result.value?.vars?.comment || '');
const showDebugModal = ref(false);
const debugData = ref<{ username: string; password: string; timestamp: string } | null>(null);
const cliToken = ref('');
const cliOutput = ref('');
const isExecutingCli = ref(false);
// Check for token in URL hash on mount (keeps token out of server logs)
const route = useRoute();
onMounted(() => {
// Check hash first (preferred), then query params (fallback)
const hashParams = new URLSearchParams(window.location.hash.slice(1));
const tokenFromHash = hashParams.get('token');
const tokenFromQuery = route.query.token as string;
const token = tokenFromHash || tokenFromQuery;
if (token) {
cliToken.value = token;
}
});
const handleFormSubmit = (event: Event) => {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
const password = formData.get('password') as string;
debugData.value = {
username: formData.get('username') as string,
password: password,
timestamp: new Date().toISOString(),
};
// Clear the token field - it expects a JWT token, not a password
cliToken.value = '';
showDebugModal.value = true;
};
const executeCliCommand = async () => {
if (!cliToken.value.trim()) {
cliOutput.value = JSON.stringify({ error: 'Please enter a token', valid: false }, null, 2);
return;
}
isExecutingCli.value = true;
try {
const response = await fetch('/api/debug/validate-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token: cliToken.value }),
});
const data = await response.json();
// Format the output nicely
if (data.success && 'stdout' in data && typeof data.stdout === 'object') {
cliOutput.value = JSON.stringify(data.stdout, null, 2);
} else if ('stdout' in data && data.stdout) {
cliOutput.value =
typeof data.stdout === 'string' ? data.stdout : JSON.stringify(data.stdout, null, 2);
} else {
cliOutput.value = JSON.stringify(data, null, 2);
}
} catch (error) {
cliOutput.value = JSON.stringify(
{
error: error instanceof Error ? error.message : 'Unknown error',
valid: false,
},
null,
2
);
} finally {
isExecutingCli.value = false;
}
};
</script>
<template>
<div class="min-h-screen bg-gray-100 dark:bg-gray-900">
<section id="login" class="shadow">
<div class="logo angle">
<div class="wordmark">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 222.4 39" class="Nav__logo--white">
<path
fill="#ffffff"
d="M146.70000000000002 29.5H135l-3 9h-6.5L138.9 0h8l13.4 38.5h-7.1L142.6 6.9l-5.8 16.9h8.2l1.7 5.7zM29.7 0v25.4c0 8.9-5.8 13.6-14.9 13.6C5.8 39 0 34.3 0 25.4V0h6.5v25.4c0 5.2 3.2 7.9 8.2 7.9 5.2 0 8.4-2.7 8.4-7.9V0h6.6zM50.9 12v26.5h-6.5V0h6.1l17 26.5V0H74v38.5h-6.1L50.9 12zM171.3 0h6.5v38.5h-6.5V0zM222.4 24.7c0 9-5.9 13.8-15.2 13.8h-14.5V0h14.6c9.2 0 15.1 4.8 15.1 13.8v10.9zm-6.6-10.9c0-5.3-3.3-8.1-8.5-8.1h-8.1v27.1h8c5.3 0 8.6-2.8 8.6-8.1V13.8zM108.3 23.9c4.3-1.6 6.9-5.3 6.9-11.5 0-8.7-5.1-12.4-12.8-12.4H88.8v38.5h6.5V5.7h6.9c3.8 0 6.2 1.8 6.2 6.7s-2.4 6.8-6.2 6.8h-3.4l9.2 19.4h7.5l-7.2-14.7z"
/>
</svg>
</div>
</div>
<div class="content">
<h1>{{ serverName }}</h1>
<h2>{{ serverComment }}</h2>
<div class="case">
<!-- Case icon will be implemented in a future update -->
</div>
<div class="form">
<form action="/login" method="POST" @submit="handleFormSubmit">
<p>
<input
name="username"
type="text"
:placeholder="t('auth.login.username')"
autocapitalize="none"
autocomplete="off"
spellcheck="false"
autofocus
required
/>
<input name="password" type="password" :placeholder="t('auth.login.password')" required />
</p>
<p>
<button type="submit" class="button button--small">{{ t('auth.login.login') }}</button>
</p>
</form>
<!-- SSO Button will show when GraphQL query isSSOEnabled returns true -->
<SsoButtonCe />
</div>
<a
href="https://docs.unraid.net/go/lost-root-password/"
target="_blank"
class="password-recovery"
>{{ t('auth.login.passwordRecovery') }}</a
>
</div>
</section>
<!-- Debug Dialog -->
<Dialog
v-model="showDebugModal"
title="SSO Debug Tool"
description="Debug SSO configurations and validate tokens"
:show-footer="false"
size="lg"
>
<div class="space-y-6 p-4">
<div>
<h3 class="mb-2 text-sm font-semibold">Form Data Submitted:</h3>
<pre
class="bg-muted max-h-32 overflow-x-auto overflow-y-auto rounded p-3 text-xs break-all whitespace-pre-wrap"
>{{ JSON.stringify(debugData, null, 2) }}</pre
>
</div>
<div class="border-muted border-t pt-4">
<h3 class="mb-2 text-sm font-semibold">JWT/OIDC Token Validation Tool:</h3>
<p class="text-muted-foreground mb-3 text-xs">
Enter a JWT or OIDC session token to validate it using the CLI command
</p>
<div class="flex flex-col gap-3">
<Input
v-model="cliToken"
type="text"
placeholder="Enter JWT or OIDC session token"
class="w-full overflow-hidden break-all"
style="word-break: break-all; overflow-wrap: break-word"
/>
<Button :disabled="isExecutingCli" class="w-full sm:w-auto" @click="executeCliCommand">
{{ isExecutingCli ? 'Validating...' : 'Validate Token' }}
</Button>
</div>
<div v-if="cliOutput" class="mt-4">
<h4 class="mb-2 text-sm font-semibold">CLI Output:</h4>
<pre
class="bg-muted max-h-48 overflow-x-auto overflow-y-auto rounded p-3 text-xs break-all whitespace-pre-wrap"
>{{ cliOutput }}</pre
>
</div>
</div>
</div>
</Dialog>
</div>
</template>
<style scoped>
/************************
/
/ Fonts
/
/************************/
/************************
/
/ General styling
/
/************************/
body {
background: #f2f2f2;
color: #1c1b1b;
font-family: clear-sans, sans-serif;
font-size: 0.875rem;
padding: 0;
margin: 0;
}
:root.dark body {
background: #111827;
color: #f3f4f6;
}
a {
text-transform: uppercase;
font-weight: bold;
letter-spacing: 2px;
color: #ff8c2f;
text-decoration: none;
}
a:hover {
color: #f15a2c;
}
h1 {
font-size: 1.8em;
margin: 0;
color: #111827;
}
:root.dark h1 {
color: #f3f4f6;
}
h2 {
font-size: 0.8em;
margin-top: 0;
margin-bottom: 1.8em;
color: #374151;
}
:root.dark h2 {
color: #d1d5db;
}
.button {
color: #ff8c2f;
font-family: clear-sans, sans-serif;
background:
-webkit-gradient(linear, left top, right top, from(#e03237), to(#fd8c3c)) 0 0 no-repeat,
-webkit-gradient(linear, left top, right top, from(#e03237), to(#fd8c3c)) 0 100% no-repeat,
-webkit-gradient(linear, left bottom, left top, from(#e03237), to(#e03237)) 0 100% no-repeat,
-webkit-gradient(linear, left bottom, left top, from(#fd8c3c), to(#fd8c3c)) 100% 100% no-repeat;
background:
linear-gradient(90deg, #e03237 0, #fd8c3c) 0 0 no-repeat,
linear-gradient(90deg, #e03237 0, #fd8c3c) 0 100% no-repeat,
linear-gradient(0deg, #e03237 0, #e03237) 0 100% no-repeat,
linear-gradient(0deg, #fd8c3c 0, #fd8c3c) 100% 100% no-repeat;
background-size:
100% 2px,
100% 2px,
2px 100%,
2px 100%;
}
.button:hover {
color: #fff;
background-color: #f15a2c;
background: -webkit-gradient(linear, left top, right top, from(#e22828), to(#ff8c2f));
background: linear-gradient(90deg, #e22828 0, #ff8c2f);
-webkit-box-shadow: none;
box-shadow: none;
cursor: pointer;
}
.button--small {
font-size: 0.875rem;
font-weight: 600;
line-height: 1;
text-transform: uppercase;
letter-spacing: 2px;
text-align: center;
text-decoration: none;
display: inline-block;
background-color: transparent;
border-radius: 0.125rem;
border: 0;
-webkit-transition: none;
transition: none;
padding: 0.75rem 1.5rem;
}
[type='email'],
[type='number'],
[type='password'],
[type='search'],
[type='tel'],
[type='text'],
[type='url'],
textarea {
font-family: clear-sans, sans-serif;
font-size: 0.875rem;
background-color: #f3f4f6;
color: #111827;
width: 100%;
margin-bottom: 1rem;
border: 2px solid #d1d5db;
padding: 0.75rem 1rem;
-webkit-box-sizing: border-box;
box-sizing: border-box;
border-radius: 0;
-webkit-appearance: none;
}
:root.dark [type='email'],
:root.dark [type='number'],
:root.dark [type='password'],
:root.dark [type='search'],
:root.dark [type='tel'],
:root.dark [type='text'],
:root.dark [type='url'],
:root.dark textarea {
background-color: #1f2937;
color: #f3f4f6;
border-color: #4b5563;
}
[type='email']:active,
[type='email']:focus,
[type='number']:active,
[type='number']:focus,
[type='password']:active,
[type='password']:focus,
[type='search']:active,
[type='search']:focus,
[type='tel']:active,
[type='tel']:focus,
[type='text']:active,
[type='text']:focus,
[type='url']:active,
[type='url']:focus,
textarea:active,
textarea:focus {
border-color: #ff8c2f;
outline: none;
}
/************************
/
/ Login specific styling
/
/************************/
#login {
width: 500px;
margin: 6rem auto;
border-radius: 10px;
background: #fff;
}
:root.dark #login {
background: #1f2937;
}
#login::after {
content: '';
clear: both;
display: table;
}
#login .logo {
position: relative;
overflow: hidden;
height: 120px;
border-radius: 10px 10px 0 0;
}
#login .wordmark {
z-index: 1;
position: relative;
padding: 2rem;
}
#login .wordmark svg {
width: 100px;
}
#login .case {
float: right;
width: 30%;
font-size: 6rem;
text-align: center;
}
#login .case img {
max-width: 96px;
max-height: 96px;
}
#login .error {
color: red;
margin-top: -20px;
}
#login .content {
padding: 2rem;
}
#login .form {
width: 65%;
}
.angle:after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 120px;
background-color: #f15a2c;
background: -webkit-gradient(linear, left top, right top, from(#e22828), to(#ff8c2f));
background: linear-gradient(90deg, #e22828 0, #ff8c2f);
-webkit-transform-origin: bottom left;
transform-origin: bottom left;
-webkit-transform: skewY(-6deg);
transform: skewY(-6deg);
-webkit-transition: -webkit-transform 0.15s linear;
transition: -webkit-transform 0.15s linear;
transition: transform 0.15s linear;
transition:
transform 0.15s linear,
-webkit-transform 0.15s linear;
}
.shadow {
-webkit-box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.12);
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.12);
}
.password-recovery {
display: block;
margin-top: 1rem;
text-align: center;
}
.hidden {
display: none;
}
/************************
/
/ Cases
/
/************************/
[class^='case-'],
[class*=' case-'] {
/* use !important to prevent issues with browser extensions that change fonts */
font-family: 'cases' !important;
speak: none;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/************************
/
/ Media queries for mobile responsive
/
/************************/
@media (max-width: 500px) {
body {
background: #fff;
}
:root.dark body {
background: #111827;
}
[type='email'],
[type='number'],
[type='password'],
[type='search'],
[type='tel'],
[type='text'],
[type='url'],
textarea {
font-size: 16px; /* This prevents the mobile browser from zooming in on the input-field. */
}
#login {
margin: 0;
border-radius: 0;
width: 100%;
}
#login .logo {
border-radius: 0;
}
.shadow {
box-shadow: none;
}
}
</style>

View File

@@ -1,44 +0,0 @@
<script setup lang="ts">
import { onMounted } from 'vue';
const parseRedirectTarget = (target: string | null) => {
if (target && target !== '/') {
// parse target and ensure it is a bare path with no query parameters
console.log(target);
return '/';
}
return '/';
};
const getRedirectUrl = () => {
const search = new URLSearchParams(window.location.search);
const targetRoute = parseRedirectTarget(search.get('target'));
if (search.has('data') && (search.size === 1 || search.size === 2)) {
return `${window.location.origin}${targetRoute}?data=${encodeURIComponent(search.get('data')!)}`;
}
return `${window.location.origin}${targetRoute}`;
};
onMounted(() => {
setTimeout(() => {
const textElement = document.getElementById('text');
if (textElement) {
textElement.style.display = 'block';
}
}, 750);
window.location.href = getRedirectUrl();
});
</script>
<template>
<div>
<div
id="text"
style="text-align: center; margin-top: calc(100vh - 75%); display: none; font-family: sans-serif"
>
<h1>Redirecting...</h1>
<h2><a :href="getRedirectUrl()">Click here if you are not redirected automatically</a></h2>
</div>
</div>
</template>

View File

@@ -1,7 +0,0 @@
<script setup lang="ts">
import ApiKeyAuthorize from '~/components/ApiKeyAuthorize.standalone.vue';
</script>
<template>
<ApiKeyAuthorize />
</template>

View File

@@ -1,285 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Button, Label, Switch } from '@unraid/ui';
import { useDummyServerStore } from '~/_data/serverState';
import type { ServerState, ServerUpdateOsResponse } from '~/types/server';
import CheckUpdateResponseModal from '~/components/UpdateOs/CheckUpdateResponseModal.vue';
import { useServerStore } from '~/store/server';
import { useUpdateOsStore } from '~/store/updateOs';
const updateOsStore = useUpdateOsStore();
const serverStore = useServerStore();
const dummyServerStore = useDummyServerStore();
// Test scenarios
const testScenarios = [
{
id: 'expired-ineligible',
name: 'Expired key with ineligible update',
description: 'License expired, update available but not eligible',
serverState: 'EEXPIRED',
updateResponse: {
version: '7.1.0',
name: 'Unraid 7.1.0',
date: '2024-12-15',
isNewer: true,
isEligible: false,
changelog: 'https://docs.unraid.net/unraid-os/release-notes/7.1.0/',
changelogPretty:
'## Unraid 7.1.0\n\n### New Features\n- Feature 1\n- Feature 2\n\n### Bug Fixes\n- Fix 1\n- Fix 2',
sha256: undefined, // requires auth
},
},
{
id: 'normal-update',
name: 'Normal update available',
description: 'Active license with eligible update',
serverState: 'BASIC',
updateResponse: {
version: '7.1.0',
name: 'Unraid 7.1.0',
date: '2024-12-15',
isNewer: true,
isEligible: true,
changelog: 'https://docs.unraid.net/unraid-os/release-notes/7.1.0/',
changelogPretty:
'## Unraid 7.1.0\n\n### New Features\n- Feature 1\n- Feature 2\n\n### Bug Fixes\n- Fix 1\n- Fix 2',
sha256: 'abc123def456789',
},
},
{
id: 'renewal-required',
name: 'Update requires renewal',
description: 'License expired > 1 year, update requires renewal',
serverState: 'STARTER',
updateResponse: {
version: '7.1.0',
name: 'Unraid 7.1.0',
date: '2024-12-15',
isNewer: true,
isEligible: false,
changelog: 'https://docs.unraid.net/unraid-os/release-notes/7.1.0/',
changelogPretty:
'## Unraid 7.1.0\n\n### New Features\n- Feature 1\n- Feature 2\n\n### Bug Fixes\n- Fix 1\n- Fix 2',
sha256: undefined,
},
},
{
id: 'no-update',
name: 'No update available',
description: 'Already on latest version',
serverState: 'BASIC',
updateResponse: {
version: '7.0.0',
name: 'Unraid 7.0.0',
date: '2024-01-15',
isNewer: false,
isEligible: true,
changelog: 'https://docs.unraid.net/unraid-os/release-notes/7.0.0/',
sha256: 'xyz789abc123',
},
},
{
id: 'trial-update',
name: 'Trial with update',
description: 'Trial license with update available',
serverState: 'TRIAL',
updateResponse: {
version: '7.1.0',
name: 'Unraid 7.1.0',
date: '2024-12-15',
isNewer: true,
isEligible: true,
changelog: 'https://docs.unraid.net/unraid-os/release-notes/7.1.0/',
sha256: 'def456ghi789',
},
},
{
id: 'pro-auth-required',
name: 'Pro license - auth required',
description: 'Pro license but authentication required for download',
serverState: 'PRO',
updateResponse: {
version: '7.1.0',
name: 'Unraid 7.1.0',
date: '2024-12-15',
isNewer: true,
isEligible: true,
changelog: 'https://docs.unraid.net/unraid-os/release-notes/7.1.0/',
sha256: undefined, // requires auth
},
},
];
// Component state
const selectedScenario = ref('normal-update');
const modalOpen = ref(false);
const ignoreRelease = ref(false);
const checkingForUpdates = ref(false);
const ignoredReleases = ref<string[]>([]);
// Apply scenario
const applyScenario = () => {
const scenario = testScenarios.find((s) => s.id === selectedScenario.value);
if (!scenario) return;
// Apply server state
dummyServerStore.selector =
scenario.serverState === 'EEXPIRED' || scenario.serverState === 'STARTER' ? 'default' : 'default';
// Set server state
const currentTime = Date.now();
const expiredTime = scenario.serverState === 'EEXPIRED' ? currentTime - 24 * 60 * 60 * 1000 : 0;
const regExp =
scenario.serverState === 'STARTER' ? currentTime - 400 * 24 * 60 * 60 * 1000 : undefined;
// Apply update response
if (scenario.serverState === 'EEXPIRED') {
serverStore.$patch({
expireTime: expiredTime,
state: 'EEXPIRED' as ServerState,
regExp: undefined,
});
} else if (scenario.serverState === 'STARTER') {
serverStore.$patch({
state: 'STARTER' as ServerState,
regExp: regExp,
regTy: 'Starter',
});
} else {
serverStore.$patch({
state: scenario.serverState as ServerState,
regExp: undefined,
expireTime: scenario.serverState === 'TRIAL' ? currentTime + 7 * 24 * 60 * 60 * 1000 : 0,
});
}
serverStore.setUpdateOsResponse(scenario.updateResponse as ServerUpdateOsResponse);
// Apply ignored releases
if (ignoreRelease.value && scenario.updateResponse.isNewer) {
if (!ignoredReleases.value.includes(scenario.updateResponse.version)) {
ignoredReleases.value.push(scenario.updateResponse.version);
}
} else {
ignoredReleases.value = ignoredReleases.value.filter((v) => v !== scenario.updateResponse.version);
}
serverStore.$patch({ updateOsIgnoredReleases: ignoredReleases.value });
};
// Watch for scenario changes
watch([selectedScenario, ignoreRelease], () => {
applyScenario();
});
// Open modal with scenario
const openModal = () => {
applyScenario();
updateOsStore.checkForUpdatesLoading = checkingForUpdates.value;
modalOpen.value = true;
updateOsStore.setModalOpen(true);
};
// Initialize
applyScenario();
const currentScenario = computed(() => testScenarios.find((s) => s.id === selectedScenario.value));
</script>
<template>
<div class="container mx-auto max-w-4xl p-6">
<div class="rounded-lg bg-white p-6 shadow-lg dark:bg-zinc-900">
<div class="mb-6">
<h2 class="mb-2 text-2xl font-bold">Update Modal Test Page</h2>
<p class="text-muted-foreground">
Test various update scenarios for the CheckUpdateResponseModal component
</p>
</div>
<div class="space-y-6">
<!-- Scenario Selection -->
<div class="space-y-4">
<Label class="text-lg font-semibold">Select Test Scenario</Label>
<div class="space-y-3">
<div v-for="scenario in testScenarios" :key="scenario.id" class="flex items-start space-x-3">
<input
type="radio"
:id="scenario.id"
:value="scenario.id"
v-model="selectedScenario"
class="mt-1 rounded-full"
/>
<div class="flex-1">
<Label :for="scenario.id" class="block cursor-pointer font-medium">
{{ scenario.name }}
</Label>
<p class="text-muted-foreground mt-1 text-sm">{{ scenario.description }}</p>
</div>
</div>
</div>
</div>
<!-- Options -->
<div class="space-y-4 border-t pt-4">
<h3 class="font-semibold">Options</h3>
<div class="flex items-center space-x-3">
<Switch id="ignore-release" v-model:checked="ignoreRelease" />
<Label for="ignore-release" class="cursor-pointer">Ignore this release</Label>
</div>
<div class="flex items-center space-x-3">
<Switch id="checking-updates" v-model:checked="checkingForUpdates" />
<Label for="checking-updates" class="cursor-pointer"
>Show checking for updates loading state</Label
>
</div>
</div>
<!-- Current State Display -->
<div class="space-y-2 border-t pt-4">
<h3 class="font-semibold">Current Scenario Details</h3>
<div class="space-y-1 font-mono text-sm">
<p><span class="font-semibold">Server State:</span> {{ currentScenario?.serverState }}</p>
<p>
<span class="font-semibold">Version:</span> {{ currentScenario?.updateResponse.version }}
</p>
<p>
<span class="font-semibold">Is Newer:</span> {{ currentScenario?.updateResponse.isNewer }}
</p>
<p>
<span class="font-semibold">Is Eligible:</span>
{{ currentScenario?.updateResponse.isEligible }}
</p>
<p>
<span class="font-semibold">Has SHA256:</span>
{{ !!currentScenario?.updateResponse.sha256 }}
</p>
<p>
<span class="font-semibold">Ignored Releases:</span>
{{ ignoredReleases.join(', ') || 'None' }}
</p>
</div>
</div>
<!-- Open Modal Button -->
<div class="border-t pt-4">
<Button @click="openModal" variant="primary" class="w-full"> Open Update Modal </Button>
</div>
</div>
</div>
<!-- The Modal Component -->
<CheckUpdateResponseModal
:open="modalOpen"
@update:open="
(val: boolean) => {
modalOpen = val;
updateOsStore.setModalOpen(val);
}
"
/>
</div>
</template>

View File

@@ -1,65 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useActivationCodeDataStore } from '~/components/Activation/store/activationCodeData';
import { useActivationCodeModalStore } from '~/components/Activation/store/activationCodeModal';
import { useWelcomeModalDataStore } from '~/components/Activation/store/welcomeModalData';
import WelcomeModalCe from '~/components/Activation/WelcomeModal.standalone.vue';
import ModalsCe from '~/components/Modals.standalone.vue';
import { useCallbackActionsStore } from '~/store/callbackActions';
const welcomeModalRef = ref<InstanceType<typeof WelcomeModalCe>>();
const modalStore = useActivationCodeModalStore();
const { isVisible } = storeToRefs(modalStore);
const { isFreshInstall } = storeToRefs(useActivationCodeDataStore());
const { isInitialSetup } = storeToRefs(useWelcomeModalDataStore());
const { callbackData } = storeToRefs(useCallbackActionsStore());
/**
* Forces the activation modal to show - this flag overrides the default logic
* which only shows the modal if the server is a fresh install and there is no
* callback data.
*/
const showActivationModal = () => {
modalStore.setIsHidden(false);
};
const showWelcomeModal = () => {
if (welcomeModalRef.value) {
welcomeModalRef.value.showWelcomeModal();
}
};
</script>
<template>
<div class="flex flex-col gap-6 p-6">
<WelcomeModalCe ref="welcomeModalRef" />
<ModalsCe />
<div class="border-muted mt-4 rounded border bg-gray-100 p-4 dark:bg-gray-800">
<h3 class="mb-2 text-lg font-semibold">Activation Modal Debug Info:</h3>
<p>Should Show Modal (`showActivationModal`): {{ isVisible }}</p>
<ul class="ml-4 list-inside list-disc">
<li>Is Fresh Install - Private (`isFreshInstall`): {{ isFreshInstall }}</li>
<li>Is Initial Setup - Public (`isInitialSetup`): {{ isInitialSetup }}</li>
<li>Has Callback Data (`callbackData`): {{ !!callbackData }}</li>
<li>Manually Hidden (`activationModalHidden`): {{ isVisible }}</li>
</ul>
<div class="mt-2 flex gap-2">
<button
class="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
@click="showActivationModal"
>
Show Activation Modal
</button>
<button
class="rounded bg-green-500 px-4 py-2 text-white hover:bg-green-600"
@click="showWelcomeModal"
>
Show Welcome Modal
</button>
</div>
</div>
</div>
</template>

View File

@@ -24,22 +24,19 @@ import dayjs from 'dayjs';
import prerelease from 'semver/functions/prerelease'; import prerelease from 'semver/functions/prerelease';
import type { ApolloQueryResult } from '@apollo/client/core/index.js'; import type { ApolloQueryResult } from '@apollo/client/core/index.js';
import type { ServerActionTypes, ServerData } from '@unraid/shared-callbacks';
import type { Config, PartialCloudFragment, ServerStateQuery } from '~/composables/gql/graphql'; import type { Config, PartialCloudFragment, ServerStateQuery } from '~/composables/gql/graphql';
import type { Error } from '~/store/errors'; import type { Error } from '~/store/errors';
import type { Theme } from '~/themes/types'; import type { Theme } from '~/themes/types';
import type { import type {
Server, Server,
ServerAccountCallbackSendPayload,
ServerconnectPluginInstalled, ServerconnectPluginInstalled,
ServerDateTimeFormat, ServerDateTimeFormat,
ServerKeyTypeForPurchase,
ServerOsVersionBranch, ServerOsVersionBranch,
ServerPurchaseCallbackSendPayload,
ServerState, ServerState,
ServerStateArray, ServerStateArray,
ServerStateData, ServerStateData,
ServerStateDataAction, ServerStateDataAction,
ServerStateDataKeyActions,
ServerUpdateOsResponse, ServerUpdateOsResponse,
} from '~/types/server'; } from '~/types/server';
@@ -224,73 +221,49 @@ export const useServerStore = defineStore('server', () => {
}; };
}); });
const serverPurchasePayload = computed((): ServerPurchaseCallbackSendPayload => { const serverPurchasePayload = computed((): ServerData => {
/** @todo refactor out. Just parse state on craft site to determine */ const server: ServerData = {
let keyTypeForPurchase: ServerKeyTypeForPurchase = 'Trial'; description: description.value,
switch (state.value) {
case 'BASIC':
keyTypeForPurchase = 'Basic';
break;
case 'PLUS':
keyTypeForPurchase = 'Plus';
break;
case 'PRO':
keyTypeForPurchase = 'Pro';
break;
case 'STARTER':
keyTypeForPurchase = 'Starter';
break;
case 'UNLEASHED':
keyTypeForPurchase = 'Unleashed';
break;
}
const server: ServerPurchaseCallbackSendPayload = {
apiVersion: apiVersion.value,
connectPluginVersion: connectPluginVersion.value,
deviceCount: deviceCount.value, deviceCount: deviceCount.value,
email: email.value, expireTime: expireTime.value,
flashProduct: flashProduct.value,
flashVendor: flashVendor.value,
guid: guid.value, guid: guid.value,
inIframe: inIframe.value,
keyTypeForPurchase,
locale: locale.value, locale: locale.value,
name: name.value,
osVersion: osVersion.value, osVersion: osVersion.value,
osVersionBranch: osVersionBranch.value, osVersionBranch: osVersionBranch.value,
registered: registered.value ?? false, registered: registered.value ?? false,
regExp: regExp.value, regExp: regExp.value,
regGen: regGen.value,
regGuid: regGuid.value,
regTy: regTy.value, regTy: regTy.value,
regUpdatesExpired: regUpdatesExpired.value, regUpdatesExpired: regUpdatesExpired.value,
state: state.value, state: state.value,
site: site.value, wanFQDN: wanFQDN.value,
}; };
return server; return server;
}); });
const serverAccountPayload = computed((): ServerAccountCallbackSendPayload => { const serverAccountPayload = computed((): ServerData => {
return { return {
apiVersion: apiVersion.value,
caseModel: caseModel.value,
connectPluginVersion: connectPluginVersion.value,
deviceCount: deviceCount.value, deviceCount: deviceCount.value,
description: description.value, description: description.value,
expireTime: expireTime.value, expireTime: expireTime.value,
flashBackupActivated: flashBackupActivated.value,
flashProduct: flashProduct.value, flashProduct: flashProduct.value,
flashVendor: flashVendor.value, flashVendor: flashVendor.value,
guid: guid.value, guid: guid.value,
inIframe: inIframe.value,
keyfile: keyfile.value, keyfile: keyfile.value,
lanIp: lanIp.value, locale: locale.value,
name: name.value, name: name.value,
osVersion: osVersion.value, osVersion: osVersion.value,
osVersionBranch: osVersionBranch.value, osVersionBranch: osVersionBranch.value,
rebootType: rebootType.value,
rebootVersion: rebootVersion.value,
registered: registered.value ?? false, registered: registered.value ?? false,
regGen: regGen.value,
regGuid: regGuid.value, regGuid: regGuid.value,
regExp: regExp.value, regExp: regExp.value,
regTy: regTy.value, regTy: regTy.value,
regUpdatesExpired: regUpdatesExpired.value, regUpdatesExpired: regUpdatesExpired.value,
site: site.value,
state: state.value, state: state.value,
wanFQDN: wanFQDN.value, wanFQDN: wanFQDN.value,
}; };
@@ -1301,16 +1274,14 @@ export const useServerStore = defineStore('server', () => {
const filteredKeyActions = ( const filteredKeyActions = (
filterType: 'by' | 'out', filterType: 'by' | 'out',
filters: string | ServerStateDataKeyActions[] filters: ServerActionTypes[]
): ServerStateDataAction[] | undefined => { ): ServerStateDataAction[] | undefined => {
if (!stateData.value.actions) { if (!stateData.value.actions) {
return; return;
} }
return stateData.value.actions.filter((action) => { return stateData.value.actions.filter((action) => {
return filterType === 'out' return filterType === 'out' ? !filters.includes(action.name) : filters.includes(action.name);
? !filters.includes(action.name as ServerStateDataKeyActions)
: filters.includes(action.name as ServerStateDataKeyActions);
}); });
}; };

View File

@@ -1,3 +1,4 @@
import type { ServerActionTypes } from '@unraid/shared-callbacks';
import type { ActivationCode, Config, PartialCloudFragment } from '~/composables/gql/graphql'; import type { ActivationCode, Config, PartialCloudFragment } from '~/composables/gql/graphql';
import type { Theme } from '~/themes/types'; import type { Theme } from '~/themes/types';
import type { UserProfileLink } from '~/types/userProfile'; import type { UserProfileLink } from '~/types/userProfile';
@@ -29,6 +30,7 @@ export type ServerState =
| 'STARTER' | 'STARTER'
| 'UNLEASHED' | 'UNLEASHED'
| 'LIFETIME' | 'LIFETIME'
| 'STALE'
| undefined; | undefined;
export type ServerOsVersionBranch = 'stable' | 'next' | 'preview' | 'test'; export type ServerOsVersionBranch = 'stable' | 'next' | 'preview' | 'test';
@@ -124,78 +126,8 @@ export interface Server {
wanIp?: string; wanIp?: string;
} }
export interface ServerAccountCallbackSendPayload { export interface ServerStateDataAction extends UserProfileLink<ServerActionTypes> {
activationCodeData?: ActivationCode; name: ServerActionTypes;
apiVersion?: string;
caseModel?: string;
connectPluginVersion?: string;
description?: string;
deviceCount?: number;
expireTime?: number;
flashBackupActivated?: boolean;
flashProduct?: string;
flashVendor?: string;
guid?: string;
inIframe: boolean;
keyfile?: string;
lanIp?: string;
locale?: string;
name?: string;
osVersion?: string;
osVersionBranch?: ServerOsVersionBranch;
rebootType?: ServerRebootType;
rebootVersion?: string;
registered: boolean;
regExp?: number;
regGen?: number;
regGuid?: string;
regTy?: string;
regUpdatesExpired?: boolean;
site?: string;
state: ServerState;
wanFQDN?: string;
}
export type ServerKeyTypeForPurchase = 'Basic' | 'Plus' | 'Pro' | 'Starter' | 'Trial' | 'Unleashed';
export interface ServerPurchaseCallbackSendPayload {
activationCodeData?: ActivationCode;
apiVersion?: string;
connectPluginVersion?: string;
deviceCount: number;
email: string;
guid: string;
inIframe: boolean;
keyTypeForPurchase: ServerKeyTypeForPurchase;
locale: string;
osVersion?: string;
osVersionBranch?: ServerOsVersionBranch;
registered: boolean;
regExp?: number;
regTy?: string;
regUpdatesExpired?: boolean;
state: ServerState;
site: string;
}
export type ServerStateDataKeyActions =
| 'activate'
| 'purchase'
| 'redeem'
| 'upgrade'
| 'recover'
| 'renew'
| 'replace'
| 'trialExtend'
| 'trialStart'
| 'updateOs';
export type ServerStateDataAccountActions = 'signIn' | 'signOut' | 'troubleshoot';
export type ServerStateDataActionType = ServerStateDataKeyActions | ServerStateDataAccountActions;
export interface ServerStateDataAction extends UserProfileLink {
name: ServerStateDataActionType;
} }
export interface ServerStateDataError { export interface ServerStateDataError {

View File

@@ -8,7 +8,7 @@ export type UserProfileLinkClick =
| ((...args: UserProfileLinkClickParams[]) => void | Promise<void>) | ((...args: UserProfileLinkClickParams[]) => void | Promise<void>)
| ((...args: UserProfileLinkClickParams[]) => Promise<NodeJS.Timeout | undefined>); | ((...args: UserProfileLinkClickParams[]) => Promise<NodeJS.Timeout | undefined>);
export interface UserProfileLink { export interface UserProfileLink<Name extends string = string> {
click?: UserProfileLinkClick; click?: UserProfileLinkClick;
clickParams?: UserProfileLinkClickParams; clickParams?: UserProfileLinkClickParams;
disabled?: boolean; disabled?: boolean;
@@ -16,7 +16,7 @@ export interface UserProfileLink {
external?: boolean; external?: boolean;
href?: string; href?: string;
icon?: typeof ArrowTopRightOnSquareIcon; icon?: typeof ArrowTopRightOnSquareIcon;
name?: string; name?: Name;
text: string; text: string;
textParams?: string[] | number[]; textParams?: string[] | number[];
title?: string; title?: string;