Completely redesign the UI
@ -1,6 +1,10 @@
|
||||
VITE_SSO_ORIGIN=http://localhost:9090
|
||||
VITE_OAUTH2_CLIENT_ID=b5a9a8df-827c-4925-b1c1-1940abcf356b
|
||||
VITE_OAUTH2_CLIENT_SCOPE=openid profile name
|
||||
|
||||
VITE_LOGOUT_PAGE=http://localhost:9095/logout
|
||||
|
||||
VITE_API_VIOLET=http://localhost:9095/v1/violet
|
||||
VITE_API_ORCHID=http://localhost:9095/v1/orchid
|
||||
VITE_API_AZALEA=http://localhost:9095/v1/azalea
|
||||
VITE_API_SITE_HOSTING=http://localhost:9095/v1/sites
|
||||
|
@ -1,6 +1,10 @@
|
||||
VITE_SSO_ORIGIN=https://sso.1f349.com
|
||||
VITE_OAUTH2_CLIENT_ID=9b11a141-bcb8-4140-9c88-531a5d7bf15d
|
||||
VITE_OAUTH2_CLIENT_SCOPE=openid profile name
|
||||
|
||||
VITE_LOGOUT_PAGE=https://1f349.com
|
||||
|
||||
VITE_API_VIOLET=https://api.1f349.com/v1/violet
|
||||
VITE_API_ORCHID=https://api.1f349.com/v1/orchid
|
||||
VITE_API_AZALEA=https://api.1f349.com/v1/azalea
|
||||
VITE_API_SITE_HOSTING=https://sites.1f349.com/api.php
|
||||
|
BIN
public/fonts/IosevkaCustom-Regular.woff2
Normal file
BIN
public/fonts/IosevkaCustomNerdFont-Regular.ttf
Normal file
BIN
public/fonts/Ubuntu-Bold.ttf
Normal file
BIN
public/fonts/Ubuntu-Regular.ttf
Normal file
Before Width: | Height: | Size: 5.8 MiB |
379
src/App.svelte
@ -1,138 +1,269 @@
|
||||
<script lang="ts">
|
||||
import {onMount, type SvelteComponent} from "svelte";
|
||||
import GeneralView from "./views/GeneralView.svelte";
|
||||
import GeneralView from "./views/HomeView.svelte";
|
||||
import RoutesView from "./views/RoutesView.svelte";
|
||||
import RedirectsView from "./views/RedirectsView.svelte";
|
||||
import CertificatesView from "./views/CertificatesView.svelte";
|
||||
import SitesView from "./views/SitesView.svelte";
|
||||
import {loginStore, parseJwt, type LoginStore} from "./stores/login";
|
||||
import {domainOption} from "./stores/domain-option";
|
||||
import {loginStore} from "./stores/login";
|
||||
import {domainOption, domainOptions, setDomainOption} from "./stores/domain-option";
|
||||
import {LOGIN} from "./utils/login";
|
||||
import DomainIcon from "./icons/Domain.svelte";
|
||||
import HomeIcon from "./icons/Home.svelte";
|
||||
import CertificateIcon from "./icons/Certificate.svelte";
|
||||
import RouteIcon from "./icons/Route.svelte";
|
||||
import SiteIcon from "./icons/Site.svelte";
|
||||
import RedirectIcon from "./icons/Redirect.svelte";
|
||||
import MenuIcon from "./icons/Menu.svelte";
|
||||
import StatusIcon from "./icons/Status.svelte";
|
||||
import SourceIcon from "./icons/Source.svelte";
|
||||
import DomainsView from "./views/DomainsView.svelte";
|
||||
import ChevronDownIcon from "./icons/ChevronDown.svelte";
|
||||
import ChevronUpIcon from "./icons/ChevronUp.svelte";
|
||||
import Popover from "./components/Popover.svelte";
|
||||
import UserPopover from "./components/popover/UserPopover.svelte";
|
||||
|
||||
let sidebarOptions: Array<{name: string; view: typeof SvelteComponent<{}>}> = [
|
||||
{name: "General", view: GeneralView},
|
||||
{name: "Routes", view: RoutesView},
|
||||
{name: "Redirects", view: RedirectsView},
|
||||
{name: "Certificates", view: CertificatesView},
|
||||
{name: "Sites", view: SitesView},
|
||||
type SidebarTab = {name: string; icon: typeof SvelteComponent<{}>; view: typeof SvelteComponent<{}>};
|
||||
|
||||
let sidebarOptions: Array<SidebarTab> = [
|
||||
{name: "Home", icon: HomeIcon, view: GeneralView},
|
||||
{name: "Routes", icon: RouteIcon, view: RoutesView},
|
||||
{name: "Redirects", icon: RedirectIcon, view: RedirectsView},
|
||||
{name: "Certificates", icon: CertificateIcon, view: CertificatesView},
|
||||
{name: "Domains", icon: DomainIcon, view: DomainsView},
|
||||
{name: "Sites", icon: SiteIcon, view: SitesView},
|
||||
];
|
||||
let sidebarSelection: {name: string; view: typeof SvelteComponent<{}>} = sidebarOptions[0];
|
||||
if (!import.meta.env.VITE_API_AZALEA) sidebarOptions = sidebarOptions.filter(x => x.name !== "Domains");
|
||||
|
||||
let sidebarSelection: SidebarTab = findSidebarTab(localStorage.getItem("sidebar-tab") || "");
|
||||
|
||||
function findSidebarTab(name: string): SidebarTab {
|
||||
return sidebarOptions.find(x => x.name === name) || sidebarOptions[0];
|
||||
}
|
||||
|
||||
function setSidebarTab(name: string) {
|
||||
sidebarSelection = findSidebarTab(name);
|
||||
localStorage.setItem("sidebar-tab", name);
|
||||
}
|
||||
|
||||
let tokenPerms: string[] = [];
|
||||
$: tokenPerms = [];
|
||||
|
||||
let domainOptions: string[];
|
||||
$: domainOptions = getDomainOptions($loginStore);
|
||||
let sidebarOpen: boolean = localStorage.getItem("sidebar-open") == "yes";
|
||||
|
||||
function getDomainOptions(login: LoginStore | null) {
|
||||
let accessToken = login?.tokens?.access;
|
||||
if (accessToken == null) return [];
|
||||
let jwt = parseJwt(accessToken);
|
||||
if (!jwt) return [];
|
||||
return jwt.per.filter((x: string) => x.startsWith("domain:owns=")).map((x: string) => x.slice("domain:owns=".length));
|
||||
function toggleSidebar() {
|
||||
sidebarOpen = !sidebarOpen;
|
||||
localStorage.setItem("sidebar-open", sidebarOpen ? "yes" : "no");
|
||||
}
|
||||
|
||||
let userDropdownOpen: boolean = false;
|
||||
|
||||
function toggleUserDropdown() {
|
||||
userDropdownOpen = !userDropdownOpen;
|
||||
}
|
||||
|
||||
let hasPopover: boolean;
|
||||
$: hasPopover = userDropdownOpen;
|
||||
|
||||
onMount(() => {
|
||||
LOGIN.init();
|
||||
LOGIN.userinfo(false);
|
||||
LOGIN.userinfo();
|
||||
});
|
||||
</script>
|
||||
|
||||
<header>
|
||||
<div>
|
||||
<h1>🍉 Admin Panel</h1>
|
||||
</div>
|
||||
<div class="flex-gap" />
|
||||
<nav id="sidebar" class:sidebar-open={sidebarOpen}>
|
||||
<button class="title" on:click={() => setSidebarTab("")}>
|
||||
<div class="icon">🍉</div>
|
||||
<div class="text">1f349</div>
|
||||
</button>
|
||||
{#each sidebarOptions as item (item.name)}
|
||||
<button class="header-tab" on:click={() => (sidebarSelection = item)} class:selected={item == sidebarSelection}>{item.name}</button>
|
||||
<button on:click={() => setSidebarTab(item.name)} class:selected={item == sidebarSelection}>
|
||||
{#if item.icon != null}
|
||||
<div class="icon"><svelte:component this={item.icon} /></div>
|
||||
{/if}
|
||||
<div class="text">{item.name}</div>
|
||||
</button>
|
||||
{/each}
|
||||
<div class="flex-gap" />
|
||||
<div class="nav-link">
|
||||
<a href="https://status.1f349.com" target="_blank">Status</a>
|
||||
</div>
|
||||
{#if $loginStore == null}
|
||||
<div class="login-view">
|
||||
<button on:click={() => LOGIN.userinfo(true)}>Login</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="user-view">
|
||||
<img class="user-avatar" src={$loginStore.userinfo.picture} alt="{$loginStore.userinfo.name}'s profile picture" />
|
||||
<div class="user-display-name">{$loginStore.userinfo.name}</div>
|
||||
<button
|
||||
on:click={() => {
|
||||
$loginStore = null;
|
||||
localStorage.removeItem("login-session");
|
||||
LOGIN.logout();
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
<a href="https://status.1f349.com" target="_blank" class="status">
|
||||
<div class="icon"><StatusIcon /></div>
|
||||
<div class="text">Status</div>
|
||||
</a>
|
||||
<a href="https://github.com/1f349/admin.1f349.com" target="_blank">
|
||||
<div class="icon"><SourceIcon /></div>
|
||||
<div class="text">{import.meta.env.VITE_APP_VERSION}</div>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div id="root" class:hasPopover>
|
||||
<div id="sidebar-gap" class:sidebar-open={sidebarOpen} />
|
||||
<div id="content">
|
||||
<header>
|
||||
<button id="menu-toggle" on:click={() => toggleSidebar()}>
|
||||
<MenuIcon />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
<main>
|
||||
{#if $loginStore == null}
|
||||
<div id="login-view">Please login to continue</div>
|
||||
{:else}
|
||||
<div id="option-view">
|
||||
<svelte:component this={sidebarSelection.view} />
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
<footer>
|
||||
<div class="meta-version">
|
||||
Version: <code>{import.meta.env.VITE_APP_VERSION}</code>
|
||||
, {import.meta.env.VITE_APP_LASTMOD}
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://github.com/1f349/admin.1f349.com" target="_blank">Source</a>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<span>Domain:</span>
|
||||
<select bind:value={$domainOption}>
|
||||
<option value="*">All</option>
|
||||
{#each domainOptions as domain}
|
||||
<select value={$domainOption} on:change={x => setDomainOption(x.currentTarget.value)}>
|
||||
{#each $domainOptions as domain}
|
||||
<option value={domain}>{domain}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</footer>
|
||||
<div class="flex-gap" />
|
||||
<div class="nav-link"></div>
|
||||
{#if $loginStore != null}
|
||||
<div style="position:relative;">
|
||||
<button id="user-dropdown" on:click={() => toggleUserDropdown()}>
|
||||
<img class="user-avatar" src={$loginStore.userinfo.picture} alt="{$loginStore.userinfo.name}'s profile picture" />
|
||||
<div class="user-display-name">{$loginStore.userinfo.name}</div>
|
||||
<div>
|
||||
{#if userDropdownOpen}
|
||||
<ChevronUpIcon />
|
||||
{:else}
|
||||
<ChevronDownIcon />
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{#if userDropdownOpen}
|
||||
<Popover on:click={() => (userDropdownOpen = false)}>
|
||||
<UserPopover />
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
<div id="content-inner">
|
||||
<main>
|
||||
{#if $loginStore == null}
|
||||
<div id="login-view">Please login to continue</div>
|
||||
{:else}
|
||||
<svelte:component this={sidebarSelection.view} />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import "values.scss";
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#sidebar-gap {
|
||||
width: 50px;
|
||||
transition: linear 100ms width;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.sidebar-open {
|
||||
width: $sidebar-width;
|
||||
}
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: $sidebar-width-short;
|
||||
transition: linear 100ms width;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: hidden;
|
||||
z-index: 999;
|
||||
background-color: $theme-sidebar;
|
||||
height: 100vh;
|
||||
|
||||
button,
|
||||
a {
|
||||
@include button-reset;
|
||||
|
||||
font-family: "Iosevka";
|
||||
font-weight: 400;
|
||||
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
padding: 8px 13px;
|
||||
text-wrap: nowrap;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
line-height: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
|
||||
.text {
|
||||
line-height: 24px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
&:hover:not(.title) {
|
||||
background-color: $sidebar-highlight;
|
||||
}
|
||||
}
|
||||
|
||||
button.selected {
|
||||
background-color: $sidebar-highlight;
|
||||
}
|
||||
|
||||
button.title {
|
||||
height: $header-height;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
background-color: $theme-header;
|
||||
|
||||
.icon {
|
||||
font-size: 140%;
|
||||
}
|
||||
}
|
||||
|
||||
&.sidebar-open,
|
||||
&:hover {
|
||||
width: $sidebar-width;
|
||||
}
|
||||
}
|
||||
|
||||
#menu-toggle {
|
||||
@include button-green-highlight;
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
#content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
|
||||
main {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 70px;
|
||||
padding: 0 32px;
|
||||
background-color: #2c2c2c;
|
||||
box-shadow:
|
||||
0 4px 8px #0003,
|
||||
0 6px 20px #00000030;
|
||||
gap: 16px;
|
||||
height: $header-height;
|
||||
background-color: $theme-header;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
font-size: 24px;
|
||||
}
|
||||
#user-dropdown {
|
||||
@include button-green-highlight($square: false);
|
||||
|
||||
.flex-gap {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.user-view {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: 9px;
|
||||
padding: 9px 12px;
|
||||
aspect-ratio: none;
|
||||
width: auto;
|
||||
height: 50px;
|
||||
|
||||
.user-avatar {
|
||||
width: 32px;
|
||||
@ -141,68 +272,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
box-sizing: border-box;
|
||||
color: tomato;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
padding: 8px;
|
||||
border-radius: 0.375rem;
|
||||
|
||||
&.header-tab {
|
||||
box-shadow: none;
|
||||
box-sizing: border-box;
|
||||
color: tomato;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
|
||||
&:hover {
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
#content #content-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
align-items: stretch;
|
||||
height: 0;
|
||||
margin-inline: 32px;
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
align-items: stretch;
|
||||
font-size: 14px;
|
||||
|
||||
#login-view {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
#option-view {
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: center;
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
padding: 24px 0px 32px;
|
||||
transition: opacity 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 8px;
|
||||
background-color: #2c2c2c55;
|
||||
box-shadow:
|
||||
0 -4px 8px #0003,
|
||||
0 -6px 20px #00000030;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
|
147
src/app.scss
@ -1,10 +1,4 @@
|
||||
$theme-text: rgba(255, 255, 255, 0.87);
|
||||
$theme-bg: #242424;
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
$theme-text: #213547;
|
||||
$theme-bg: #ffffff;
|
||||
}
|
||||
@import "values.scss";
|
||||
|
||||
:root {
|
||||
font-family: Ubuntu, Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
@ -23,17 +17,21 @@ $theme-bg: #242424;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-image: url('/jason-leem-50bzI1F6urA-unsplash.jpg');
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/Ubuntu.woff2') format('woff2');
|
||||
src: url('/fonts/Ubuntu-Regular.ttf') format('truetype');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/fonts/Ubuntu-Bold.ttf') format('truetype');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@ -44,6 +42,14 @@ body {
|
||||
src: url('/fonts/FiraCode-Regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Iosevka";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/IosevkaCustomNerdFont-Regular.ttf') format('truetype');
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
@ -66,107 +72,50 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.flex-gap {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
code,
|
||||
.code-font {
|
||||
font-family: 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.btn-green,
|
||||
.btn-red,
|
||||
.btn-blue,
|
||||
.btn-yellow {
|
||||
display: table-cell;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
box-sizing: border-box;
|
||||
color: black;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
height: 50px;
|
||||
padding: 4px 16px;
|
||||
vertical-align: middle;
|
||||
box-shadow: 0 4px 8px #0003, 0 6px 20px #00000030;
|
||||
table {
|
||||
border-spacing: 0px;
|
||||
border-collapse: initial;
|
||||
|
||||
thead,
|
||||
tbody tr {
|
||||
background-color: $table-row;
|
||||
|
||||
&:hover {
|
||||
color: black;
|
||||
}
|
||||
background-color: $table-row-highlight;
|
||||
}
|
||||
|
||||
.btn-green {
|
||||
background-color: #209c6f;
|
||||
th {
|
||||
background-color: $table-header;
|
||||
padding: 10px 15px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
table.main-table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
|
||||
thead {
|
||||
background-color: #33333355;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 9999;
|
||||
box-shadow: 0 4px 8px #0003, 0 6px 20px #00000030;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 6px 8px 6px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
tr:nth-child(2n) {
|
||||
background-color: #2a2a2a55;
|
||||
}
|
||||
|
||||
tr:nth-child(2n + 1) {
|
||||
background-color: #24242455;
|
||||
}
|
||||
|
||||
.invert-rows {
|
||||
tr:nth-child(2n) {
|
||||
background-color: #24242455;
|
||||
}
|
||||
|
||||
tr:nth-child(2n + 1) {
|
||||
background-color: #2a2a2a55;
|
||||
}
|
||||
display: table-cell;
|
||||
vertical-align: inherit;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1rem;
|
||||
padding: 0px 15px;
|
||||
white-space: nowrap;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
&.action-table tbody tr:not(.empty-row) td:last-child {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
margin: 2em;
|
||||
|
||||
backdrop-filter: blur(32px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(32px) saturate(180%);
|
||||
background-color: rgba(17, 25, 40, 0.75);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.125);
|
||||
|
||||
.scrolling-area {
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.text-padding {
|
||||
padding: 4px 16px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
height: 50px;
|
||||
background-color: #2c2c2c55;
|
||||
box-shadow: 0 -4px 8px #0003, 0 -6px 20px #00000030;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.meta-info {
|
||||
line-height: 50px;
|
||||
padding-inline: 16px;
|
||||
justify-content: flex-end;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
45
src/components/ActionMenu.svelte
Normal file
@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import EditIcon from "../icons/Edit.svelte";
|
||||
import RemoveIcon from "../icons/Remove.svelte";
|
||||
|
||||
type T = $$Generic<any>;
|
||||
|
||||
export let data: T;
|
||||
export let edit: ((t: T) => void) | null;
|
||||
export let remove: ((t: T) => void) | null;
|
||||
</script>
|
||||
|
||||
<div class="action-menu">
|
||||
{#if edit != null}
|
||||
<button class="edit" on:click={() => edit(data)}>
|
||||
<EditIcon />
|
||||
</button>
|
||||
{/if}
|
||||
{#if remove != null}
|
||||
<button class="remove" on:click={() => remove(data)}>
|
||||
<RemoveIcon />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import "../values.scss";
|
||||
|
||||
.action-menu {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
||||
button {
|
||||
display: block;
|
||||
height: 100%;
|
||||
|
||||
&.edit {
|
||||
@include button-green-highlight;
|
||||
}
|
||||
|
||||
&.remove {
|
||||
@include button-red-highlight;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
70
src/components/ActionPopup.svelte
Normal file
@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import Popup from "./Popup.svelte";
|
||||
import XIcon from "../icons/X.svelte";
|
||||
import {createEventDispatcher} from "svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let name: string;
|
||||
export let show: boolean = false;
|
||||
|
||||
function save() {
|
||||
dispatch("save");
|
||||
}
|
||||
|
||||
function close() {
|
||||
show = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popup bind:show on:click={() => close()}>
|
||||
<div class="popup-inner">
|
||||
<div class="title-row">
|
||||
<h2>{name}</h2>
|
||||
<button class="close-button" on:click={() => close()}>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
<slot />
|
||||
<div class="button-row">
|
||||
<button class="btn-close" on:click={() => close()}>Close</button>
|
||||
<button class="btn-save" on:click={() => save()}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
|
||||
<style lang="scss">
|
||||
@import "../values.scss";
|
||||
|
||||
.popup-inner {
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
||||
.close-button {
|
||||
@include button-red-highlight;
|
||||
}
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
|
||||
.btn-close {
|
||||
@include button-green-invert-box;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
@include button-green-box;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -61,6 +61,7 @@
|
||||
<style lang="scss">
|
||||
.dropdown-check-list {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
> button {
|
||||
margin: 0;
|
||||
@ -121,6 +122,10 @@
|
||||
|
||||
> div {
|
||||
margin-right: 6px;
|
||||
|
||||
> label {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
35
src/components/Popover.svelte
Normal file
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
export let layer: number = 1000;
|
||||
</script>
|
||||
|
||||
<button id="popover" style="--popover-layer: {layer};" on:click />
|
||||
|
||||
<div id="popover-content" style="--popover-layer: {layer};">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import "../values.scss";
|
||||
|
||||
#popover {
|
||||
@include button-reset;
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: var(--popover-layer);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#popover-content {
|
||||
z-index: calc(var(--popover-layer) + 1);
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
min-width: 100%;
|
||||
background-color: $theme-header;
|
||||
cursor: auto;
|
||||
}
|
||||
</style>
|
72
src/components/Popup.svelte
Normal file
@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
export let layer: number = 2000;
|
||||
export let show: boolean = false;
|
||||
</script>
|
||||
|
||||
<button id="popup-backdrop" class:show style="--popup-layer: {layer};" on:click />
|
||||
|
||||
<div id="popup" class:show style="--popup-layer:{layer};">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import "../values.scss";
|
||||
|
||||
#popup-backdrop {
|
||||
@include button-reset;
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: var(--popup-layer);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: default;
|
||||
background-color: rgba(0,0,0,0.55);
|
||||
display: none;
|
||||
|
||||
&.show {
|
||||
display: block;
|
||||
animation: fade 0.2s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#popup {
|
||||
z-index: calc(var(--popup-layer) + 1);
|
||||
position: fixed;
|
||||
top: 50vh;
|
||||
left: 50vw;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: $theme-header;
|
||||
cursor: auto;
|
||||
min-width: 400px;
|
||||
display: none;
|
||||
|
||||
@media (max-width: 400px) {
|
||||
min-width: 100vw;
|
||||
}
|
||||
|
||||
&.show {
|
||||
display: block;
|
||||
animation: zoom 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes zoom {
|
||||
from {
|
||||
transform: translate(-50%, -50%) scale(0.95);
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
19
src/components/PromiseLike.svelte
Normal file
@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type {IPromiseLike} from "../utils/promise-like";
|
||||
|
||||
type T = $$Generic<IPromiseLike>;
|
||||
|
||||
export let value: T;
|
||||
|
||||
function hasError(value: T) {
|
||||
return value.error() != null;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $value.isLoading()}
|
||||
<slot name="loading" />
|
||||
{:else if hasError($value)}
|
||||
<slot name="error" reason={$value.error()} />
|
||||
{:else}
|
||||
<slot name="ok" value={$value} />
|
||||
{/if}
|
35
src/components/PromiseTable.svelte
Normal file
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import PromiseLike from "./PromiseLike.svelte";
|
||||
import type {IPromiseLike} from "../utils/promise-like";
|
||||
|
||||
type T = $$Generic<IPromiseLike>;
|
||||
|
||||
export let value: T;
|
||||
</script>
|
||||
|
||||
<PromiseLike {value}>
|
||||
<div class="text-padding" slot="loading">
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="text-padding" slot="error" let:reason>
|
||||
<div>Administrator... I hardly know her?</div>
|
||||
<div>{reason}</div>
|
||||
</div>
|
||||
|
||||
<table class="action-table" slot="ok" let:value>
|
||||
<thead>
|
||||
<slot name="headers" {value} />
|
||||
</thead>
|
||||
<tbody>
|
||||
<slot name="rows" {value} />
|
||||
</tbody>
|
||||
</table>
|
||||
</PromiseLike>
|
||||
|
||||
<style lang="scss">
|
||||
.text-padding,
|
||||
.text-padding div {
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
@ -1,49 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {createEventDispatcher} from "svelte";
|
||||
import {redirectKeys, type Redirect} from "../types/target";
|
||||
import Flags from "./Flags.svelte";
|
||||
import RedirectCode from "./RedirectCode.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let redirect: Redirect = {src: "", dst: "", desc: "", flags: 0, code: 302, active: true};
|
||||
|
||||
const descCols = 50;
|
||||
</script>
|
||||
|
||||
<tr class="created">
|
||||
<td><input type="text" class="code-font" bind:value={redirect.src} size={Math.max(20, redirect.src.length + 2)} /></td>
|
||||
<td><input type="text" class="code-font" bind:value={redirect.dst} size={Math.max(20, redirect.dst.length + 2)} /></td>
|
||||
<td><Flags bind:value={redirect.flags} editable keys={redirectKeys} /></td>
|
||||
<td><RedirectCode bind:code={redirect.code} editable /></td>
|
||||
<td class="desc"><textarea rows="3" cols={descCols} bind:value={redirect.desc} /></td>
|
||||
<td><input type="checkbox" bind:checked={redirect.active} /></td>
|
||||
<td>
|
||||
<button
|
||||
on:click={() => {
|
||||
dispatch("make", redirect);
|
||||
redirect = {src: "", dst: "", desc: "", flags: 0, code: 302, active: true};
|
||||
}}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<style lang="scss">
|
||||
tr:nth-child(2n) {
|
||||
background-color: #2a2a2a55;
|
||||
}
|
||||
|
||||
tr:nth-child(2n + 1) {
|
||||
background-color: #24242455;
|
||||
}
|
||||
|
||||
tr.created {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.desc textarea {
|
||||
resize: none;
|
||||
}
|
||||
</style>
|
@ -1,81 +1,60 @@
|
||||
<script lang="ts">
|
||||
import type {Writable} from "svelte/store";
|
||||
import {type CSPair, noCPair, noSPair, yesCPair} from "../types/cspair";
|
||||
import {redirectKeys, redirectEqual, type Redirect} from "../types/target";
|
||||
import {redirectKeys, type Redirect} from "../types/target";
|
||||
import Flags from "./Flags.svelte";
|
||||
import RedirectCode from "./RedirectCode.svelte";
|
||||
import type {RestItem} from "../utils/rest-table";
|
||||
import ActionMenu from "./ActionMenu.svelte";
|
||||
import ActionPopup from "./ActionPopup.svelte";
|
||||
|
||||
export let value: Writable<CSPair<Redirect>>;
|
||||
export let value: RestItem<Redirect>;
|
||||
let editItem: Redirect = {
|
||||
src: "",
|
||||
dst: "",
|
||||
flags: 0,
|
||||
code: 0,
|
||||
active: false,
|
||||
};
|
||||
|
||||
let item: CSPair<Redirect>;
|
||||
$: item = $value;
|
||||
let editPopup: boolean = false;
|
||||
|
||||
function resetRedirect(): any {
|
||||
item.client = JSON.parse(JSON.stringify(item.server));
|
||||
function save() {
|
||||
value.update(editItem);
|
||||
}
|
||||
|
||||
const descCols = 50;
|
||||
</script>
|
||||
|
||||
{#if noCPair(item)}
|
||||
<tr class="deleted">
|
||||
<td class="code-font"><a href="https://{item.server.src}" target="_blank">{item.server.src}</a></td>
|
||||
<td><input type="text" class="code-font" disabled bind:value={item.server.dst} size={Math.max(20, item.server.dst.length + 2)} /></td>
|
||||
<td><Flags value={item.server.flags} keys={redirectKeys} /></td>
|
||||
<td><RedirectCode bind:code={item.server.code} /></td>
|
||||
<td class="desc"><textarea rows="3" cols={descCols} disabled value={item.server.desc} /></td>
|
||||
<td><input type="checkbox" disabled checked={false} /></td>
|
||||
<td><button on:click={() => resetRedirect()}>Restore</button></td>
|
||||
</tr>
|
||||
{:else if yesCPair(item)}
|
||||
<tr class:created={noSPair(item)} class:modified={!noSPair(item) && !redirectEqual(item.client, item.server)}>
|
||||
<td class="code-font"><a href="https://{item.client.src}" target="_blank">{item.client.src}</a></td>
|
||||
<td><input type="text" class="code-font" bind:value={item.client.dst} size={Math.max(20, item.client.dst.length + 2)} /></td>
|
||||
<td><Flags bind:value={item.client.flags} editable keys={redirectKeys} /></td>
|
||||
<td><RedirectCode bind:code={item.client.code} editable /></td>
|
||||
<td class="desc"><textarea rows="3" cols={descCols} bind:value={item.client.desc} /></td>
|
||||
<td><input type="checkbox" bind:checked={item.client.active} /></td>
|
||||
<tr>
|
||||
<td class="code-font"><a href="https://{value.data.src}" target="_blank">{value.data.src}</a></td>
|
||||
<td>{value.data.dst}</td>
|
||||
<td><Flags bind:value={value.data.flags} keys={redirectKeys} /></td>
|
||||
<td><RedirectCode bind:code={value.data.code} /></td>
|
||||
<td><input type="checkbox" disabled checked={value.data.active} /></td>
|
||||
<td>
|
||||
{#if !noSPair(item)}
|
||||
<button on:click={() => resetRedirect()}>Reset</button>
|
||||
{/if}
|
||||
<button on:click={() => (item.client = null)}>Delete</button>
|
||||
<ActionMenu
|
||||
data={value}
|
||||
edit={() => {
|
||||
editItem = JSON.parse(JSON.stringify(value.data));
|
||||
editPopup = true;
|
||||
}}
|
||||
remove={() => value.remove()}
|
||||
/>
|
||||
|
||||
<ActionPopup name="Edit Redirect" bind:show={editPopup} on:save={save}>
|
||||
<div>Source</div>
|
||||
<div class="code-font">{editItem.src}</div>
|
||||
<div>Destination</div>
|
||||
<div><input type="text" class="code-font" bind:value={editItem.dst} size={Math.max(20, editItem.dst.length + 2)} /></div>
|
||||
<div>Flags</div>
|
||||
<div><Flags bind:value={editItem.flags} editable keys={redirectKeys} /></div>
|
||||
<div>Redirect Code</div>
|
||||
<div><RedirectCode bind:code={editItem.code} editable /></div>
|
||||
<div>Active</div>
|
||||
<div><input type="checkbox" bind:checked={editItem.active} /></div>
|
||||
</ActionPopup>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<div>Invalid redirect row: please report this error</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
tr.created {
|
||||
background-color: #1a510077;
|
||||
|
||||
&:nth-child(2n) {
|
||||
background-color: #10330077;
|
||||
}
|
||||
}
|
||||
|
||||
tr.modified {
|
||||
background-color: #51510077;
|
||||
|
||||
&:nth-child(2n) {
|
||||
background-color: #33330077;
|
||||
}
|
||||
}
|
||||
|
||||
tr.deleted {
|
||||
background-color: #51000077;
|
||||
|
||||
&:nth-child(2n) {
|
||||
background-color: #33000077;
|
||||
}
|
||||
}
|
||||
|
||||
td input[type="text"] {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.desc textarea {
|
||||
resize: none;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,47 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {createEventDispatcher} from "svelte";
|
||||
import {routeKeys, type Route} from "../types/target";
|
||||
import Flags from "./Flags.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let route: Route = {src: "", dst: "", desc: "", flags: 0, active: true};
|
||||
|
||||
const descCols = 50;
|
||||
</script>
|
||||
|
||||
<tr class="created">
|
||||
<td><input type="text" class="code-font" bind:value={route.src} size={Math.max(20, route.src.length + 2)} /></td>
|
||||
<td><input type="text" class="code-font" bind:value={route.dst} size={Math.max(20, route.dst.length + 2)} /></td>
|
||||
<td><Flags bind:value={route.flags} editable keys={routeKeys} /></td>
|
||||
<td class="desc"><textarea rows="3" cols={descCols} bind:value={route.desc} /></td>
|
||||
<td><input type="checkbox" bind:checked={route.active} /></td>
|
||||
<td>
|
||||
<button
|
||||
on:click={() => {
|
||||
dispatch("make", route);
|
||||
route = {src: "", dst: "", desc: "", flags: 0, active: true};
|
||||
}}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<style lang="scss">
|
||||
tr:nth-child(2n) {
|
||||
background-color: #2a2a2a55;
|
||||
}
|
||||
|
||||
tr:nth-child(2n + 1) {
|
||||
background-color: #24242455;
|
||||
}
|
||||
|
||||
tr.created {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.desc textarea {
|
||||
resize: none;
|
||||
}
|
||||
</style>
|
@ -1,78 +1,55 @@
|
||||
<script lang="ts">
|
||||
import type {Writable} from "svelte/store";
|
||||
import {type CSPair, noCPair, noSPair, yesCPair} from "../types/cspair";
|
||||
import {type Route, routeKeys, routeEqual} from "../types/target";
|
||||
import {type Route, routeKeys} from "../types/target";
|
||||
import Flags from "./Flags.svelte";
|
||||
import type {RestItem} from "../utils/rest-table";
|
||||
import ActionMenu from "./ActionMenu.svelte";
|
||||
import ActionPopup from "./ActionPopup.svelte";
|
||||
|
||||
export let value: Writable<CSPair<Route>>;
|
||||
export let value: RestItem<Route>;
|
||||
let editItem: Route = {
|
||||
src: "",
|
||||
dst: "",
|
||||
flags: 0,
|
||||
active: false,
|
||||
};
|
||||
|
||||
let item: CSPair<Route>;
|
||||
$: item = $value;
|
||||
let editPopup: boolean = false;
|
||||
|
||||
function resetRoute(): any {
|
||||
item.client = JSON.parse(JSON.stringify(item.server));
|
||||
function save() {
|
||||
value.update(editItem);
|
||||
}
|
||||
|
||||
const descCols = 50;
|
||||
</script>
|
||||
|
||||
{#if noCPair(item)}
|
||||
<tr class="deleted">
|
||||
<td class="code-font"><a href="https://{item.server.src}" target="_blank">{item.server.src}</a></td>
|
||||
<td><input type="text" class="code-font" disabled bind:value={item.server.dst} size={Math.max(20, item.server.dst.length + 2)} /></td>
|
||||
<td><Flags value={item.server.flags} keys={routeKeys} /></td>
|
||||
<td class="desc"><textarea rows="3" cols={descCols} disabled value={item.server.desc} /></td>
|
||||
<td><input type="checkbox" disabled checked={false} /></td>
|
||||
<td><button on:click={() => resetRoute()}>Restore</button></td>
|
||||
</tr>
|
||||
{:else if yesCPair(item)}
|
||||
<tr class:created={noSPair(item)} class:modified={!noSPair(item) && !routeEqual(item.client, item.server)}>
|
||||
<td class="code-font"><a href="https://{item.client.src}" target="_blank">{item.client.src}</a></td>
|
||||
<td><input type="text" class="code-font" bind:value={item.client.dst} size={Math.max(20, item.client.dst.length + 2)} /></td>
|
||||
<td><Flags bind:value={item.client.flags} editable keys={routeKeys} /></td>
|
||||
<td class="desc"><textarea rows="3" cols={descCols} bind:value={item.client.desc} /></td>
|
||||
<td><input type="checkbox" bind:checked={item.client.active} /></td>
|
||||
<tr>
|
||||
<td class="code-font"><a href="https://{value.data.src}" target="_blank">{value.data.src}</a></td>
|
||||
<td>{value.data.dst}</td>
|
||||
<td><Flags bind:value={value.data.flags} keys={routeKeys} /></td>
|
||||
<td><input type="checkbox" disabled checked={value.data.active} /></td>
|
||||
<td>
|
||||
{#if !noSPair(item)}
|
||||
<button on:click={() => resetRoute()}>Reset</button>
|
||||
{/if}
|
||||
<button on:click={() => (item.client = null)}>Delete</button>
|
||||
<ActionMenu
|
||||
data={value}
|
||||
edit={() => {
|
||||
editItem = JSON.parse(JSON.stringify(value.data));
|
||||
editPopup = true;
|
||||
}}
|
||||
remove={() => value.remove()}
|
||||
/>
|
||||
|
||||
<ActionPopup name="Edit Route" bind:show={editPopup} on:save={save}>
|
||||
<div>Source</div>
|
||||
<div><input type="text" class="code-font" bind:value={editItem.src} size={Math.max(20, value.data.dst.length + 2)} /></div>
|
||||
<div>Destination</div>
|
||||
<div><input type="text" class="code-font" bind:value={editItem.dst} size={Math.max(20, editItem.dst.length + 2)} /></div>
|
||||
<div>Flags</div>
|
||||
<div><Flags bind:value={editItem.flags} editable keys={routeKeys} /></div>
|
||||
<div>Active</div>
|
||||
<div><input type="checkbox" bind:checked={editItem.active} /></div>
|
||||
</ActionPopup>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<div>Invalid redirect row: please report this error</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
tr.created {
|
||||
background-color: #1a510077;
|
||||
|
||||
&:nth-child(2n) {
|
||||
background-color: #10330077;
|
||||
}
|
||||
}
|
||||
|
||||
tr.modified {
|
||||
background-color: #51510077;
|
||||
|
||||
&:nth-child(2n) {
|
||||
background-color: #33330077;
|
||||
}
|
||||
}
|
||||
|
||||
tr.deleted {
|
||||
background-color: #51000077;
|
||||
|
||||
&:nth-child(2n) {
|
||||
background-color: #33000077;
|
||||
}
|
||||
}
|
||||
|
||||
td input[type="text"] {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.desc textarea {
|
||||
resize: none;
|
||||
}
|
||||
</style>
|
||||
|
29
src/components/popover/UserPopover.svelte
Normal file
@ -0,0 +1,29 @@
|
||||
<script>
|
||||
import {LOGIN} from "../../utils/login";
|
||||
</script>
|
||||
|
||||
<div class="user-popover">
|
||||
<a href="https://sso.1f349.com" target="_blank">1f349 Profile</a>
|
||||
<button on:click={() => LOGIN.logout()}>Log Out</button>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import "../../values.scss";
|
||||
|
||||
.user-popover {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
button,
|
||||
a {
|
||||
@include button-reset;
|
||||
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
color: #10b981;
|
||||
text-decoration: underline;
|
||||
padding: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
17
src/icons/Certificate.svelte
Normal file
@ -0,0 +1,17 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-shield-check"
|
||||
>
|
||||
<path
|
||||
d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"
|
||||
/>
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
After Width: | Height: | Size: 458 B |
14
src/icons/ChevronDown.svelte
Normal file
@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-chevron-down"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
After Width: | Height: | Size: 273 B |
14
src/icons/ChevronUp.svelte
Normal file
@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-chevron-up"
|
||||
>
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</svg>
|
After Width: | Height: | Size: 273 B |
16
src/icons/Domain.svelte
Normal file
@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-globe"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
After Width: | Height: | Size: 361 B |
15
src/icons/Edit.svelte
Normal file
@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-pencil"
|
||||
>
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</svg>
|
After Width: | Height: | Size: 408 B |
15
src/icons/Home.svelte
Normal file
@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-home"
|
||||
>
|
||||
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
After Width: | Height: | Size: 345 B |
16
src/icons/Menu.svelte
Normal file
@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-menu"
|
||||
>
|
||||
<line x1="4" x2="20" y1="12" y2="12" />
|
||||
<line x1="4" x2="20" y1="6" y2="6" />
|
||||
<line x1="4" x2="20" y1="18" y2="18" />
|
||||
</svg>
|
After Width: | Height: | Size: 361 B |
16
src/icons/Redirect.svelte
Normal file
@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-milestone"
|
||||
>
|
||||
<path d="M18 6H5a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h13l4-3.5L18 6Z" />
|
||||
<path d="M12 13v8" />
|
||||
<path d="M12 3v3" />
|
||||
</svg>
|
After Width: | Height: | Size: 357 B |
16
src/icons/Remove.svelte
Normal file
@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-trash"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
</svg>
|
After Width: | Height: | Size: 364 B |
16
src/icons/Route.svelte
Normal file
@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-route"
|
||||
>
|
||||
<circle cx="6" cy="19" r="3" />
|
||||
<path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15" />
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
</svg>
|
After Width: | Height: | Size: 374 B |
16
src/icons/Site.svelte
Normal file
@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-panels-top-left"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M3 9h18" />
|
||||
<path d="M9 21V9" />
|
||||
</svg>
|
After Width: | Height: | Size: 347 B |
17
src/icons/Source.svelte
Normal file
@ -0,0 +1,17 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-github"
|
||||
>
|
||||
<path
|
||||
d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"
|
||||
/>
|
||||
<path d="M9 18c-4.51 2-5-2-7-2" />
|
||||
</svg>
|
After Width: | Height: | Size: 546 B |
17
src/icons/Status.svelte
Normal file
@ -0,0 +1,17 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-traffic-cone"
|
||||
>
|
||||
<path d="M9.3 6.2a4.55 4.55 0 0 0 5.4 0" />
|
||||
<path d="M7.9 10.7c.9.8 2.4 1.3 4.1 1.3s3.2-.5 4.1-1.3" />
|
||||
<path d="M13.9 3.5a1.93 1.93 0 0 0-3.8-.1l-3 10c-.1.2-.1.4-.1.6 0 1.7 2.2 3 5 3s5-1.3 5-3c0-.2 0-.4-.1-.5Z" />
|
||||
<path d="m7.5 12.2-4.7 2.7c-.5.3-.8.7-.8 1.1s.3.8.8 1.1l7.6 4.5c.9.5 2.1.5 3 0l7.6-4.5c.7-.3 1-.7 1-1.1s-.3-.8-.8-1.1l-4.7-2.8" />
|
||||
</svg>
|
After Width: | Height: | Size: 598 B |
15
src/icons/X.svelte
Normal file
@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-x"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
After Width: | Height: | Size: 286 B |
@ -1,30 +0,0 @@
|
||||
import {writable} from "svelte/store";
|
||||
|
||||
export interface Cert {
|
||||
id: number;
|
||||
auto_renew: boolean;
|
||||
active: boolean;
|
||||
renewing: boolean;
|
||||
renew_failed: boolean;
|
||||
not_after: string;
|
||||
updated_at: string;
|
||||
domains: string[];
|
||||
}
|
||||
|
||||
export function siteEqual(a: Cert | null, b: Cert | null) {
|
||||
if (a == null || b == null) return false;
|
||||
a.domains.sort();
|
||||
b.domains.sort();
|
||||
return (
|
||||
a.id == b.id &&
|
||||
a.auto_renew == b.auto_renew &&
|
||||
a.active == b.active &&
|
||||
a.renewing == b.renewing &&
|
||||
a.renew_failed == b.renew_failed &&
|
||||
a.not_after == b.not_after &&
|
||||
a.updated_at == b.updated_at &&
|
||||
JSON.stringify(a.domains) == JSON.stringify(b.domains)
|
||||
);
|
||||
}
|
||||
|
||||
export const certsTable = writable<{[key: string]: Cert}>({});
|
@ -1,3 +1,25 @@
|
||||
import { writable } from "svelte/store";
|
||||
import {derived, writable} from "svelte/store";
|
||||
import {loginStore, parseJwt, type LoginStore} from "./login";
|
||||
|
||||
export const domainOption = writable<string>("*");
|
||||
export const domainOptions = derived([loginStore], ([$loginStore]) => {
|
||||
return getDomainOptions($loginStore);
|
||||
});
|
||||
|
||||
const internalDomainOption = writable<string>(localStorage.getItem("domain-option") || "");
|
||||
|
||||
export const domainOption = derived([domainOptions, internalDomainOption], ([$domainOptions, $internalDomainOption]) => {
|
||||
return $domainOptions.find(x => x === $internalDomainOption) || $domainOptions[0] || "";
|
||||
});
|
||||
|
||||
function getDomainOptions(login: LoginStore | null): string[] {
|
||||
let accessToken = login?.tokens?.access;
|
||||
if (accessToken == null) return [];
|
||||
let jwt = parseJwt(accessToken);
|
||||
if (!jwt) return [];
|
||||
return jwt.per.filter((x: string) => x.startsWith("domain:owns=")).map((x: string) => x.slice("domain:owns=".length));
|
||||
}
|
||||
|
||||
export function setDomainOption(domain: string) {
|
||||
internalDomainOption.set(domain);
|
||||
localStorage.setItem("domain-option", domain);
|
||||
}
|
||||
|
114
src/stores/records.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import {writable} from "svelte/store";
|
||||
|
||||
export interface RecordHeader {
|
||||
Name: string;
|
||||
Rrtype: number;
|
||||
Class: number;
|
||||
Ttl: number;
|
||||
}
|
||||
|
||||
export interface UnknownRecord {
|
||||
Hdr: RecordHeader;
|
||||
}
|
||||
|
||||
export interface SoaRecord extends UnknownRecord {
|
||||
Ns: string;
|
||||
Mbox: string;
|
||||
Serial: number;
|
||||
Refresh: number;
|
||||
Retry: number;
|
||||
Expire: number;
|
||||
Minttl: number;
|
||||
}
|
||||
|
||||
export function isSoaRecord(x: UnknownRecord): x is SoaRecord {
|
||||
return x.Hdr.Rrtype === 6;
|
||||
}
|
||||
|
||||
export const soaRecords = writable<Array<SoaRecord>>([]);
|
||||
|
||||
export interface NsRecord extends UnknownRecord {
|
||||
Ns: string;
|
||||
}
|
||||
|
||||
export function isNsRecord(x: UnknownRecord): x is NsRecord {
|
||||
return x.Hdr.Rrtype === 2;
|
||||
}
|
||||
|
||||
export const nsRecords = writable<Array<NsRecord>>([]);
|
||||
|
||||
export interface MxRecord extends UnknownRecord {
|
||||
Preference: number;
|
||||
Mx: string;
|
||||
}
|
||||
|
||||
export function isMxRecord(x: UnknownRecord): x is MxRecord {
|
||||
return x.Hdr.Rrtype === 15;
|
||||
}
|
||||
|
||||
export const mxRecords = writable<Array<MxRecord>>([]);
|
||||
|
||||
export interface ARecord extends UnknownRecord {
|
||||
A: string;
|
||||
}
|
||||
|
||||
export function isARecord(x: UnknownRecord): x is ARecord {
|
||||
return x.Hdr.Rrtype === 1;
|
||||
}
|
||||
|
||||
export const aRecords = writable<Array<ARecord>>([]);
|
||||
|
||||
export interface AaaaRecord extends UnknownRecord {
|
||||
AAAA: string;
|
||||
}
|
||||
|
||||
export function isAaaaRecord(x: UnknownRecord): x is AaaaRecord {
|
||||
return x.Hdr.Rrtype === 28;
|
||||
}
|
||||
|
||||
export const aaaaRecords = writable<Array<AaaaRecord>>([]);
|
||||
|
||||
export interface CnameRecord extends UnknownRecord {
|
||||
Target: string;
|
||||
}
|
||||
|
||||
export function isCnameRecord(x: UnknownRecord): x is CnameRecord {
|
||||
return x.Hdr.Rrtype === 5;
|
||||
}
|
||||
|
||||
export const cnameRecords = writable<Array<CnameRecord>>([]);
|
||||
|
||||
export interface TxtRecord extends UnknownRecord {
|
||||
Txt: Array<string>;
|
||||
}
|
||||
|
||||
export function isTxtRecord(x: UnknownRecord): x is TxtRecord {
|
||||
return x.Hdr.Rrtype === 16;
|
||||
}
|
||||
|
||||
export const txtRecords = writable<Array<TxtRecord>>([]);
|
||||
|
||||
export interface SrvRecord extends UnknownRecord {
|
||||
Priority: number;
|
||||
Weight: number;
|
||||
Port: number;
|
||||
Target: string;
|
||||
}
|
||||
|
||||
export function isSrvRecord(x: UnknownRecord): x is SrvRecord {
|
||||
return x.Hdr.Rrtype === 33;
|
||||
}
|
||||
|
||||
export const srvRecords = writable<Array<SrvRecord>>([]);
|
||||
|
||||
export interface CaaRecord extends UnknownRecord {
|
||||
Flag: number;
|
||||
Tag: string;
|
||||
Value: string;
|
||||
}
|
||||
|
||||
export function isCaaRecord(x: UnknownRecord): x is CaaRecord {
|
||||
return x.Hdr.Rrtype === 257;
|
||||
}
|
||||
|
||||
export const caaRecords = writable<Array<CaaRecord>>([]);
|
@ -1,13 +0,0 @@
|
||||
import {writable} from "svelte/store";
|
||||
|
||||
export interface Site {
|
||||
domain: string;
|
||||
branches: string[];
|
||||
}
|
||||
|
||||
export function siteEqual(a: Site | null, b: Site | null) {
|
||||
if (a == null || b == null) return false;
|
||||
return a.domain == b.domain;
|
||||
}
|
||||
|
||||
export const sitesTable = writable<{[key: string]: Site}>({});
|
@ -1,28 +0,0 @@
|
||||
import {writable} from "svelte/store";
|
||||
import type {CSPair} from "../types/cspair";
|
||||
import type {Pair} from "../utils/pair";
|
||||
import type {Redirect, Route} from "../types/target";
|
||||
|
||||
export const routesTable = writable<{[key: string]: CSPair<Route>}>({});
|
||||
export const redirectsTable = writable<{[key: string]: CSPair<Redirect>}>({});
|
||||
|
||||
function getTableArray<T>(table: {[key: string]: CSPair<T>}, keys: Array<string>): Array<Pair<string, CSPair<T>>> {
|
||||
return keys.map(x => ({a: x, b: table[x]}));
|
||||
}
|
||||
|
||||
export interface CountStats {
|
||||
created: number;
|
||||
modified: number;
|
||||
removed: number;
|
||||
}
|
||||
|
||||
export function tableCountStats<T>(table: {[key: string]: CSPair<T>}, keys: Array<string>, equality: (a: T, b: T) => boolean): CountStats {
|
||||
let list = getTableArray(table, keys)
|
||||
.map(x => x.b)
|
||||
.filter(x => x.client != null || x.server != null);
|
||||
return {
|
||||
created: list.filter(x => x.server == null).length,
|
||||
modified: list.filter(x => x.server != null && x.client != null && !equality(x.client, x.server)).length,
|
||||
removed: list.filter(x => x.client == null).length,
|
||||
};
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
export interface Route {
|
||||
src: string;
|
||||
dst: string;
|
||||
desc: string;
|
||||
flags: number;
|
||||
active: boolean;
|
||||
}
|
||||
@ -9,22 +8,11 @@ export interface Route {
|
||||
export interface Redirect {
|
||||
src: string;
|
||||
dst: string;
|
||||
desc: string;
|
||||
flags: number;
|
||||
code: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export function routeEqual(a: Route | null, b: Route | null): boolean {
|
||||
if (a == null || b == null) return false;
|
||||
return a.src === b.src && a.dst === b.dst && a.desc === b.desc && a.flags === b.flags && a.active === b.active;
|
||||
}
|
||||
|
||||
export function redirectEqual(a: Redirect | null, b: Redirect | null): boolean {
|
||||
if (a == null || b == null) return false;
|
||||
return a.src === b.src && a.dst === b.dst && a.desc === b.desc && a.flags === b.flags && a.code === b.code && a.active === b.active;
|
||||
}
|
||||
|
||||
export const routeKeys = [
|
||||
{char: "p", name: "Prefix Path"},
|
||||
{char: "a", name: "Absolute Path"},
|
||||
|
@ -1,33 +1,95 @@
|
||||
import {loginStore} from "../stores/login";
|
||||
import {POP2} from "./pop2";
|
||||
|
||||
const TOKEN_AUTHORIZE_API = import.meta.env.VITE_SSO_ORIGIN + "/authorize";
|
||||
const TOKEN_USERINFO_API = import.meta.env.VITE_SSO_ORIGIN + "/userinfo";
|
||||
export const LOGIN = (function () {
|
||||
const OAUTH2_AUTHORIZE_API = import.meta.env.VITE_SSO_ORIGIN + "/authorize";
|
||||
const OAUTH2_USERINFO_API = import.meta.env.VITE_SSO_ORIGIN + "/userinfo";
|
||||
const OAUTH2_CLIENT_ID = import.meta.env.VITE_OAUTH2_CLIENT_ID;
|
||||
const OAUTH2_CLIENT_SCOPE = import.meta.env.VITE_OAUTH2_CLIENT_SCOPE;
|
||||
const OAUTH2_LOGOUT_PAGE = import.meta.env.VITE_LOGOUT_PAGE;
|
||||
|
||||
export const LOGIN = {
|
||||
init: () => {
|
||||
POP2.init(TOKEN_AUTHORIZE_API, OAUTH2_CLIENT_ID, "openid profile name", 500, 600);
|
||||
let access_token: string | null = localStorage.getItem("oauth2_access_token"),
|
||||
redirect_uri = window.location.href.slice(0, window.location.href.length - window.location.hash.length).replace(/#$/, "");
|
||||
|
||||
if (window.location.hash.indexOf("access_token") !== -1) {
|
||||
access_token = window.location.hash.replace(/^.*access_token=([^&]+).*$/, "$1");
|
||||
localStorage.setItem("oauth2_access_token", access_token);
|
||||
history.pushState("", document.title, window.location.pathname + window.location.search);
|
||||
}
|
||||
|
||||
let hasError: boolean = false;
|
||||
if (window.location.search.indexOf("error=") !== -1) {
|
||||
localStorage.removeItem("oauth2_access_token");
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
const redirectToLogin = function () {
|
||||
window.location.href =
|
||||
OAUTH2_AUTHORIZE_API +
|
||||
"?response_type=token" +
|
||||
"&redirect_uri=" +
|
||||
encodeURIComponent(redirect_uri) +
|
||||
"&scope=" +
|
||||
encodeURIComponent(OAUTH2_CLIENT_SCOPE) +
|
||||
"&client_id=" +
|
||||
encodeURIComponent(OAUTH2_CLIENT_ID);
|
||||
};
|
||||
|
||||
const clientRequest = function (resource: RequestInfo, options: RequestInit): Promise<Response> {
|
||||
const sendRequest = function (): Promise<Response> {
|
||||
options.credentials = "include";
|
||||
if (!options.headers) options.headers = {};
|
||||
options.headers["Authorization"] = "Bearer " + access_token;
|
||||
return new Promise(function (res, rej) {
|
||||
fetch(resource, options)
|
||||
.then(function (x) {
|
||||
if (x.status >= 200 && x.status < 300) res(x);
|
||||
else throw new Error("invalid status code " + x.status);
|
||||
})
|
||||
.catch(function (x: Error) {
|
||||
if (x.message === "invalid status code 403") redirectToLogin();
|
||||
rej(x);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (access_token == null) {
|
||||
redirectToLogin();
|
||||
return Promise.reject("missing access token");
|
||||
}
|
||||
|
||||
return sendRequest();
|
||||
};
|
||||
|
||||
const LOGIN = {
|
||||
hadError: function () {
|
||||
return hasError;
|
||||
},
|
||||
logout: () => {
|
||||
POP2.logout();
|
||||
|
||||
logout: function () {
|
||||
access_token = null;
|
||||
loginStore.set(null);
|
||||
localStorage.removeItem("login-session");
|
||||
localStorage.removeItem("oauth2_access_token");
|
||||
window.location.href = OAUTH2_LOGOUT_PAGE;
|
||||
},
|
||||
clientRequest: (resource: string, options: RequestInit, refresh: boolean) => {
|
||||
return POP2.clientRequest(resource, options, refresh);
|
||||
},
|
||||
userinfo: (popup: boolean) => {
|
||||
POP2.getToken((token: string) => {
|
||||
POP2.clientRequest(TOKEN_USERINFO_API, {}, popup)
|
||||
|
||||
clientRequest,
|
||||
|
||||
userinfo: function () {
|
||||
clientRequest(OAUTH2_USERINFO_API, {})
|
||||
.then(x => x.json())
|
||||
.then(x => {
|
||||
if (access_token == null) throw new Error("missing access token");
|
||||
loginStore.set({
|
||||
userinfo: x,
|
||||
tokens: {access: token, refresh: ""},
|
||||
tokens: {access: access_token, refresh: ""},
|
||||
});
|
||||
})
|
||||
.catch(x => {
|
||||
console.error(x);
|
||||
});
|
||||
}, popup);
|
||||
},
|
||||
};
|
||||
|
||||
return LOGIN;
|
||||
})();
|
||||
|
@ -1,193 +0,0 @@
|
||||
/* Simple OAuth 2.0 Client flow library
|
||||
|
||||
Author: MrMelon54, timdream
|
||||
|
||||
Usage:
|
||||
POP2.init(client_id, scope)
|
||||
Initialize the library.
|
||||
redirect_uri is the current page (window.location.href).
|
||||
This function should be put before Analytics so that the second click won't result a page view register.
|
||||
POP2.getToken(callback)
|
||||
Send access token to the callback function as the first argument.
|
||||
If not logged in this triggers login popup and execute login after logged in.
|
||||
Be sure to call this function in user-triggered event (such as click) to prevent popup blocker.
|
||||
If not sure do use isLoggedIn() below to check first.
|
||||
POP2.isLoggedIn()
|
||||
boolean
|
||||
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
import type {RequestInfo} from "undici-types";
|
||||
|
||||
export const POP2 = (function (w) {
|
||||
const windowName = "pop2_oauth2_login_popup";
|
||||
|
||||
if (window.name === windowName) {
|
||||
if (window.opener && window.opener.POP2) {
|
||||
if (window.location.hash.indexOf("access_token") !== -1) {
|
||||
window.opener.POP2.receiveToken(
|
||||
window.location.hash.replace(/^.*access_token=([^&]+).*$/, "$1"),
|
||||
parseInt(window.location.hash.replace(/^.*expires_in=([^&]+).*$/, "$1")),
|
||||
);
|
||||
}
|
||||
if (window.location.search.indexOf("error=") !== -1) {
|
||||
window.opener.POP2.receiveToken("ERROR");
|
||||
}
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
|
||||
function popupCenterScreen(url: string | URL, title: string, w: number, h: number) {
|
||||
const top = (screen.availHeight - h) / 4,
|
||||
left = (screen.availWidth - w) / 2;
|
||||
return openWindow(url, title, `scrollbars=yes,width=${w},height=${h},top=${top},left=${left}`);
|
||||
}
|
||||
|
||||
function openWindow(url: string | URL, winnm: string | undefined, options: string) {
|
||||
const wTop = firstAvailableValue([window.screen.availTop, window.screenY, window.screenTop, 0]);
|
||||
const wLeft = firstAvailableValue([window.screen.availLeft, window.screenX, window.screenLeft, 0]);
|
||||
let top = 0,
|
||||
left = 0,
|
||||
result;
|
||||
if ((result = /top=(\d+)/g.exec(options))) top = parseInt(result[1]);
|
||||
if ((result = /left=(\d+)/g.exec(options))) left = parseInt(result[1]);
|
||||
let w;
|
||||
if (options) {
|
||||
options = options.replace("top=" + top, "top=" + (top + wTop));
|
||||
options = options.replace("left=" + left, "left=" + (left + wLeft));
|
||||
w = window.open(url, winnm, options);
|
||||
} else w = window.open(url, winnm);
|
||||
return w;
|
||||
}
|
||||
|
||||
function firstAvailableValue(arr: any[]) {
|
||||
for (let i = 0; i < arr.length; i++) if (typeof arr[i] != "undefined") return arr[i];
|
||||
}
|
||||
|
||||
let client_endpoint: string,
|
||||
client_id: string,
|
||||
scope = "",
|
||||
redirect_uri = window.location.href.slice(0, window.location.href.length - window.location.hash.length).replace(/#$/, ""),
|
||||
access_token: string | null = localStorage.getItem("pop2_access_token"),
|
||||
callbackWaitForToken: ((access_token: string) => void) | undefined,
|
||||
w_width = 400,
|
||||
w_height = 360;
|
||||
|
||||
const POP2 = {
|
||||
// init
|
||||
init: function (f_client_endpoint: string, f_client_id: string, f_scope: string, width: number, height: number) {
|
||||
if (!f_client_endpoint) return false;
|
||||
if (!f_client_id) return false;
|
||||
client_endpoint = f_client_endpoint;
|
||||
client_id = f_client_id;
|
||||
if (f_scope) scope = f_scope;
|
||||
if (width) w_width = width;
|
||||
if (height) w_height = height;
|
||||
},
|
||||
// receive token from popup
|
||||
receiveToken: function (token: string | boolean | null, expires_in: number) {
|
||||
if (typeof token === "string" && token !== "ERROR") {
|
||||
access_token = token;
|
||||
localStorage.setItem("pop2_access_token", access_token);
|
||||
if (callbackWaitForToken) callbackWaitForToken(access_token);
|
||||
setTimeout(function () {
|
||||
access_token = null;
|
||||
localStorage.removeItem("pop2_access_token");
|
||||
}, expires_in * 1000);
|
||||
} else if (token === false) {
|
||||
callbackWaitForToken = undefined;
|
||||
}
|
||||
},
|
||||
// pass the access token to callback
|
||||
// if not logged in this triggers login popup;
|
||||
// use isLoggedIn to check login first to prevent popup blocker
|
||||
getToken: function (callback: (access_token: string) => void, popup = true) {
|
||||
if (!client_id || !redirect_uri || !scope) {
|
||||
alert("You need init() first. Check the program flow.");
|
||||
return false;
|
||||
}
|
||||
if (access_token == null) {
|
||||
if (!popup) throw Error("missing access token");
|
||||
callbackWaitForToken = callback;
|
||||
popupCenterScreen(
|
||||
client_endpoint +
|
||||
"?response_type=token" +
|
||||
"&redirect_uri=" +
|
||||
encodeURIComponent(redirect_uri) +
|
||||
"&scope=" +
|
||||
encodeURIComponent(scope) +
|
||||
"&client_id=" +
|
||||
encodeURIComponent(client_id),
|
||||
windowName,
|
||||
w_width,
|
||||
w_height,
|
||||
);
|
||||
return false;
|
||||
} else {
|
||||
callback(access_token);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
logout: function () {
|
||||
access_token = null;
|
||||
localStorage.removeItem("pop2_access_token");
|
||||
},
|
||||
clientRequest: function (resource: RequestInfo, options: RequestInit, refresh = false) {
|
||||
const sendRequest = function () {
|
||||
options.credentials = "include";
|
||||
if (!options.headers) options.headers = {};
|
||||
options.headers["Authorization"] = "Bearer " + access_token;
|
||||
return new Promise(function (res, rej) {
|
||||
fetch(resource, options)
|
||||
.then(function (x) {
|
||||
if (x.status >= 200 && x.status < 300) res(x);
|
||||
else rej(x);
|
||||
})
|
||||
.catch(function (x) {
|
||||
rej(["failed to send request", x]);
|
||||
});
|
||||
});
|
||||
};
|
||||
const resendRequest = function () {
|
||||
return new Promise(function (res, rej) {
|
||||
access_token = null;
|
||||
POP2.getToken(function () {
|
||||
sendRequest()
|
||||
.then(function (x) {
|
||||
res(x);
|
||||
})
|
||||
.catch(function (x) {
|
||||
rej(["failed to resend request", x]);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (!refresh) {
|
||||
if (access_token == null) return Promise.reject("missing access token");
|
||||
return sendRequest();
|
||||
} else {
|
||||
return new Promise(function (res, rej) {
|
||||
sendRequest()
|
||||
.then(function (x) {
|
||||
res(x);
|
||||
})
|
||||
.catch(function () {
|
||||
resendRequest()
|
||||
.then(function (x) {
|
||||
res(x);
|
||||
})
|
||||
.catch(function (x) {
|
||||
rej(x);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
window.POP2 = POP2;
|
||||
return POP2;
|
||||
})();
|
6
src/utils/promise-like.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import type {Readable} from "svelte/store";
|
||||
|
||||
export interface IPromiseLike<T> extends Readable<T> {
|
||||
isLoading(): boolean;
|
||||
error(): string | null;
|
||||
}
|
138
src/utils/rest-table.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import type {Subscriber, Unsubscriber} from "svelte/store";
|
||||
import type {IPromiseLike} from "../utils/promise-like";
|
||||
import {LOGIN} from "./login";
|
||||
|
||||
export class RestTable<T extends object> implements IPromiseLike<RestTable<T>> {
|
||||
apiUrl: string;
|
||||
keyFunc: (item: T) => string;
|
||||
rows: Array<RestItem<T>>;
|
||||
private errorReason: string | null = null;
|
||||
private loading: boolean = false;
|
||||
private subs: Set<Subscriber<RestTable<T>>> = new Set();
|
||||
|
||||
constructor(apiUrl: string, keyFunc: (item: T) => string) {
|
||||
this.apiUrl = apiUrl;
|
||||
this.keyFunc = keyFunc;
|
||||
this.rows = [];
|
||||
}
|
||||
|
||||
private updateSubs() {
|
||||
this.subs.forEach(x => x(this));
|
||||
}
|
||||
|
||||
isLoading(): boolean {
|
||||
return this.loading;
|
||||
}
|
||||
|
||||
error(): string | null {
|
||||
return this.errorReason;
|
||||
}
|
||||
|
||||
reload() {
|
||||
(async () => {
|
||||
try {
|
||||
this.loading = true;
|
||||
this.updateSubs();
|
||||
let f = await LOGIN.clientRequest(this.apiUrl, {method: "GET"});
|
||||
if (f.status != 200) throw new Error("Unexpected status code: " + f.status);
|
||||
let fJson = await f.json();
|
||||
let rows = fJson as T[];
|
||||
this.rows = rows.map(x => new RestItem(this, x));
|
||||
this.loading = false;
|
||||
this.updateSubs();
|
||||
} catch (err) {
|
||||
console.log("Setting error reason");
|
||||
console.log(err);
|
||||
this.errorReason = (err as Error).message;
|
||||
this.loading = false;
|
||||
this.updateSubs();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
addItem(item: T) {
|
||||
(async () => {
|
||||
let f = await LOGIN.clientRequest(this.apiUrl, {method: "POST"});
|
||||
if (f.status !== 200) throw new Error("Unexpected status code: " + f.status);
|
||||
this.rows.push(new RestItem(this, item));
|
||||
this.updateSubs();
|
||||
})();
|
||||
}
|
||||
|
||||
subscribe(run: Subscriber<RestTable<T>>): Unsubscriber {
|
||||
this.subs.add(run);
|
||||
run(this);
|
||||
return () => this.subs.delete(run);
|
||||
}
|
||||
}
|
||||
|
||||
export class RestItem<T extends object> implements IPromiseLike<RestItem<T>> {
|
||||
table: RestTable<T>;
|
||||
data: T;
|
||||
private errorReason: string | null = null;
|
||||
private loading: boolean = false;
|
||||
private subs: Set<Subscriber<RestItem<T>>> = new Set();
|
||||
|
||||
constructor(table: RestTable<T>, data: T) {
|
||||
this.table = table;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
private updateSubs() {
|
||||
this.subs.forEach(x => x(this));
|
||||
}
|
||||
|
||||
key(): string {
|
||||
return this.table.keyFunc(this.data);
|
||||
}
|
||||
|
||||
isLoading(): boolean {
|
||||
return this.loading;
|
||||
}
|
||||
|
||||
error(): string | null {
|
||||
return this.errorReason;
|
||||
}
|
||||
|
||||
update(data: T): Promise<void> {
|
||||
this.loading = true;
|
||||
this.updateSubs();
|
||||
return LOGIN.clientRequest(this.table.apiUrl + "/" + this.key(), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(this.data),
|
||||
})
|
||||
.then(x => {
|
||||
if (x.status !== 200) throw new Error("Unexpected status code: " + x.status);
|
||||
this.data = data;
|
||||
this.loading = false;
|
||||
this.updateSubs();
|
||||
})
|
||||
.catch(x => {
|
||||
this.loading = false;
|
||||
this.updateSubs();
|
||||
});
|
||||
}
|
||||
|
||||
remove(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.updateSubs();
|
||||
return LOGIN.clientRequest(this.table.apiUrl + "/" + this.key(), {method: "DELETE"})
|
||||
.then(x => {
|
||||
if (x.status !== 200) throw new Error("Unexpected status code: " + x.status);
|
||||
this.table.rows = this.table.rows.filter(x => this.table.keyFunc(x.data) !== this.key());
|
||||
this.loading = false;
|
||||
this.updateSubs();
|
||||
})
|
||||
.catch(x => {
|
||||
this.errorReason = "Failed to remove item " + this.table.keyFunc(this.data);
|
||||
this.loading = false;
|
||||
this.updateSubs();
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(run: Subscriber<RestItem<T>>): Unsubscriber {
|
||||
this.subs.add(run);
|
||||
run(this);
|
||||
return () => this.subs.delete(run);
|
||||
}
|
||||
}
|
98
src/values.scss
Normal file
@ -0,0 +1,98 @@
|
||||
// theme
|
||||
$theme-text: rgba(255, 255, 255, 0.87);
|
||||
$theme-header: #202020;
|
||||
$theme-sidebar: #262626;
|
||||
$theme-bg: #333333;
|
||||
|
||||
// header
|
||||
$header-height: 50px;
|
||||
|
||||
// sidebar
|
||||
$sidebar-highlight: #047857;
|
||||
$sidebar-width-short: 50px;
|
||||
$sidebar-width: 232px;
|
||||
|
||||
// table
|
||||
$table-header: $theme-header;
|
||||
$table-row: $theme-sidebar;
|
||||
$table-row-highlight: $theme-header;
|
||||
|
||||
$icon-highlight: #10b981;
|
||||
|
||||
@mixin button-reset {
|
||||
padding: 0;
|
||||
border: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@mixin button-highlight($highlight, $square: true) {
|
||||
@include button-reset;
|
||||
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
line-height: 0;
|
||||
width: 40px;
|
||||
|
||||
@if $square {
|
||||
aspect-ratio: 1/1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $highlight;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin button-green-highlight($square: true) {
|
||||
@include button-highlight(#10b981, $square);
|
||||
}
|
||||
|
||||
@mixin button-red-highlight ($square: true) {
|
||||
@include button-highlight(#ef4444, $square);
|
||||
}
|
||||
|
||||
@mixin button-box($background: transparent) {
|
||||
@include button-reset;
|
||||
|
||||
display: inline-flex;
|
||||
-moz-box-align: center;
|
||||
align-items: center;
|
||||
-moz-box-pack: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
outline: 0px;
|
||||
user-select: none;
|
||||
vertical-align: middle;
|
||||
appearance: none;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
box-shadow: none;
|
||||
border: medium;
|
||||
border-radius: 1px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
min-height: 34px;
|
||||
min-width: 105px;
|
||||
text-transform: capitalize;
|
||||
transition: none;
|
||||
background-color: $background;
|
||||
padding: 2px 20px;
|
||||
}
|
||||
|
||||
@mixin button-green-box {
|
||||
@include button-box(#047857);
|
||||
}
|
||||
|
||||
@mixin button-red-box {
|
||||
@include button-box(#b91c1c);
|
||||
}
|
||||
|
||||
@mixin button-green-invert-box {
|
||||
@include button-box;
|
||||
|
||||
color: #10b981;
|
||||
}
|
@ -1,22 +1,37 @@
|
||||
<script lang="ts">
|
||||
import PromiseLike from "../components/PromiseLike.svelte";
|
||||
import PromiseTable from "../components/PromiseTable.svelte";
|
||||
import X from "../icons/X.svelte";
|
||||
import {domainOption} from "../stores/domain-option";
|
||||
import {type Cert, certsTable} from "../stores/certs";
|
||||
import {LOGIN} from "../utils/login";
|
||||
import {RestItem, RestTable} from "../utils/rest-table";
|
||||
|
||||
const apiOrchid = import.meta.env.VITE_API_ORCHID;
|
||||
|
||||
let tableKeys: string[] = [];
|
||||
$: tableKeys = Object.entries($certsTable)
|
||||
.map(x => x[1])
|
||||
.filter(x => x.domains.map(x => domainFilter(x, $domainOption)).reduce((a, b) => a || b))
|
||||
const table = new RestTable<Cert>(apiOrchid + "/owned", (item: Cert) => item.id.toString());
|
||||
|
||||
interface Cert {
|
||||
id: number;
|
||||
auto_renew: boolean;
|
||||
active: boolean;
|
||||
renewing: boolean;
|
||||
renew_failed: boolean;
|
||||
not_after: string;
|
||||
updated_at: string;
|
||||
domains: string[];
|
||||
}
|
||||
|
||||
function rowOrdering(rows: RestItem<Cert>[], domain: string): RestItem<Cert>[] {
|
||||
return rows
|
||||
.filter(x => x.data.domains.map(x => domainFilter(x, domain)).reduce((a, b) => a || b))
|
||||
.sort((a, b) => {
|
||||
// sort renew failed first
|
||||
if (a.renew_failed && b.renew_failed) return a.id - b.id;
|
||||
if (a.renew_failed) return -1;
|
||||
if (b.renew_failed) return 1;
|
||||
return a.id - b.id;
|
||||
})
|
||||
.map(x => x.id.toString());
|
||||
if (a.data.renew_failed && b.data.renew_failed) return a.data.id - b.data.id;
|
||||
if (a.data.renew_failed) return -1;
|
||||
if (b.data.renew_failed) return 1;
|
||||
return a.data.id - b.data.id;
|
||||
});
|
||||
}
|
||||
|
||||
function domainFilter(src: string, domain: string) {
|
||||
if (domain == "*") return true;
|
||||
@ -27,32 +42,15 @@
|
||||
return p.endsWith(domain);
|
||||
}
|
||||
|
||||
let promiseForTable: Promise<void> = reloadTable();
|
||||
|
||||
async function reloadTable(): Promise<void> {
|
||||
let f = await LOGIN.clientRequest(apiOrchid + "/owned", {}, false);
|
||||
if (f.status !== 200) throw new Error("Unexpected status code: " + f.status);
|
||||
let fJson = await f.json();
|
||||
let rows = fJson as Map<number, Cert>;
|
||||
Object.values(rows).forEach(x => {
|
||||
$certsTable[Object(x.id).toString()] = x;
|
||||
});
|
||||
console.log($certsTable);
|
||||
}
|
||||
domainOption.subscribe(() => table.reload());
|
||||
</script>
|
||||
|
||||
<div class="wrapper">
|
||||
<h1>Certificates</h1>
|
||||
|
||||
<div style="padding:8px;background-color:#bb7900;">Warning: This is currently still under development</div>
|
||||
|
||||
<div class="scrolling-area">
|
||||
{#await promiseForTable}
|
||||
<div class="text-padding">
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
{:then}
|
||||
<table class="main-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<PromiseTable value={table}>
|
||||
<tr slot="headers">
|
||||
<th>ID</th>
|
||||
<th>Auto Renew</th>
|
||||
<th>Active</th>
|
||||
@ -61,44 +59,47 @@
|
||||
<th>Not After</th>
|
||||
<th>Domains</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="invert-rows">
|
||||
{#each tableKeys as key (key)}
|
||||
{@const cert = $certsTable[key]}
|
||||
<tr class:cert-error={cert.renew_failed}>
|
||||
<td>{cert.id}</td>
|
||||
<td>{cert.auto_renew}</td>
|
||||
<td>{cert.active}</td>
|
||||
<td>{cert.renewing}</td>
|
||||
<td>{cert.renew_failed}</td>
|
||||
|
||||
<svelte:fragment slot="rows" let:value>
|
||||
{#each rowOrdering(value.rows, $domainOption) as item}
|
||||
<PromiseLike value={item}>
|
||||
<tr slot="loading" class="empty-row">
|
||||
<td colspan="100">
|
||||
<div>Loading...</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr slot="error" let:reason class="empty-row">
|
||||
<td colspan="100">Error loading row for {item.data.id}: {reason}</td>
|
||||
</tr>
|
||||
|
||||
<tr slot="ok" let:value class:cert-error={value.data.renew_failed} class="empty-row">
|
||||
<td>{value.data.id}</td>
|
||||
<td>{value.data.auto_renew}</td>
|
||||
<td>{value.data.active}</td>
|
||||
<td>{value.data.renewing}</td>
|
||||
<td>{value.data.renew_failed}</td>
|
||||
<td>
|
||||
<div>{cert.not_after}</div>
|
||||
<div>{Math.round((new Date(cert.not_after).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))} days until expiry</div>
|
||||
<div>{value.data.not_after}</div>
|
||||
<div>{Math.round((new Date(value.data.not_after).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))} days until expiry</div>
|
||||
</td>
|
||||
<td class="branch-cell">
|
||||
{#each cert.domains as domain}
|
||||
{#each value.data.domains as domain}
|
||||
<div>{domain}</div>
|
||||
{/each}
|
||||
</td>
|
||||
</tr>
|
||||
</PromiseLike>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:catch err}
|
||||
<div class="text-padding">
|
||||
<div>Administrator... I hardly know her?</div>
|
||||
<div>{err}</div>
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</PromiseTable>
|
||||
|
||||
<style lang="scss">
|
||||
.branch-cell {
|
||||
.branch-cell:last-child {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, auto);
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
justify-content: left;
|
||||
padding: 8px 15px;
|
||||
}
|
||||
|
||||
// css please explain yourself
|
||||
|
338
src/views/DomainsView.svelte
Normal file
@ -0,0 +1,338 @@
|
||||
<script lang="ts">
|
||||
import {LOGIN} from "../utils/login";
|
||||
import {domainOption} from "../stores/domain-option";
|
||||
import {
|
||||
aRecords,
|
||||
aaaaRecords,
|
||||
caaRecords,
|
||||
cnameRecords,
|
||||
isARecord,
|
||||
isAaaaRecord,
|
||||
isCaaRecord,
|
||||
isCnameRecord,
|
||||
isMxRecord,
|
||||
isNsRecord,
|
||||
isSoaRecord,
|
||||
isSrvRecord,
|
||||
isTxtRecord,
|
||||
mxRecords,
|
||||
nsRecords,
|
||||
soaRecords,
|
||||
srvRecords,
|
||||
txtRecords,
|
||||
type UnknownRecord,
|
||||
} from "../stores/records";
|
||||
import ActionMenu from "../components/ActionMenu.svelte";
|
||||
|
||||
const apiAzalea = import.meta.env.VITE_API_AZALEA;
|
||||
|
||||
let promiseForTable: Promise<void> = reloadTable();
|
||||
|
||||
async function reloadTable(): Promise<void> {
|
||||
let f = await LOGIN.clientRequest(apiAzalea + "/domains/" + $domainOption + "/records", {});
|
||||
if (f.status != 200) throw new Error("Unexpected status code: " + f.status);
|
||||
let fJson = await f.json();
|
||||
let rows = fJson as Array<UnknownRecord>;
|
||||
$soaRecords = rows.filter(isSoaRecord);
|
||||
$nsRecords = rows.filter(isNsRecord);
|
||||
$mxRecords = rows.filter(isMxRecord);
|
||||
$aRecords = rows.filter(isARecord);
|
||||
$aaaaRecords = rows.filter(isAaaaRecord);
|
||||
$cnameRecords = rows.filter(isCnameRecord);
|
||||
$txtRecords = rows.filter(isTxtRecord);
|
||||
$srvRecords = rows.filter(isSrvRecord);
|
||||
$caaRecords = rows.filter(isCaaRecord);
|
||||
}
|
||||
|
||||
domainOption.subscribe(x => {
|
||||
promiseForTable = reloadTable();
|
||||
});
|
||||
|
||||
function getTitleDomain(name: string): string {
|
||||
if (name.endsWith(".")) {
|
||||
return name.substring(0, name.length - 1);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await promiseForTable}
|
||||
<div class="text-padding">
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
{:then}
|
||||
{#if $soaRecords.length >= 1}
|
||||
<div class="title-row">
|
||||
<h1>Domains / {getTitleDomain($soaRecords[0].Hdr.Name)}</h1>
|
||||
<a
|
||||
class="zone-download"
|
||||
href="{import.meta.env.VITE_API_AZALEA}/domains/{getTitleDomain($soaRecords[0].Hdr.Name)}/zone-file"
|
||||
download="{getTitleDomain($soaRecords[0].Hdr.Name)}.zone"
|
||||
>
|
||||
Download DNS Zone File
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h2>SOA Record</h2>
|
||||
<table class="action-table" aria-label="List of Domains SOA Record">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Primary Domain</th>
|
||||
<th>Email</th>
|
||||
<th>Default TTL</th>
|
||||
<th>Refresh Rate</th>
|
||||
<th>Retry Rate</th>
|
||||
<th>Expire Time</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if $soaRecords.length === 0}
|
||||
<tr class="empty-row"><td colspan="7">No items to display</td></tr>
|
||||
{/if}
|
||||
{#each $soaRecords as record}
|
||||
<tr>
|
||||
<td>{record.Hdr.Name}</td>
|
||||
<td>{record.Mbox}</td>
|
||||
<td>{record.Minttl}</td>
|
||||
<td>{record.Refresh}</td>
|
||||
<td>{record.Retry}</td>
|
||||
<td>{record.Expire}</td>
|
||||
<td>
|
||||
<ActionMenu data={record} edit={t => console.log(t)} remove={null} />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>NS Record</h2>
|
||||
<table class="action-table" aria-label="List of Domains NS Record">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name Server</th>
|
||||
<th>Subdomain</th>
|
||||
<th>TTL</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if $nsRecords.length === 0}
|
||||
<tr class="empty-row"><td colspan="7">No items to display</td></tr>
|
||||
{/if}
|
||||
{#each $nsRecords as record}
|
||||
<tr>
|
||||
<td>{record.Ns}</td>
|
||||
<td>{record.Hdr.Name}</td>
|
||||
<td>{record.Hdr.Ttl}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>MX Record</h2>
|
||||
<table class="action-table" aria-label="List of Domains MX Record">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mail Server</th>
|
||||
<th>Preference</th>
|
||||
<th>Subdomain</th>
|
||||
<th>TTL</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if $mxRecords.length === 0}
|
||||
<tr class="empty-row"><td colspan="7">No items to display</td></tr>
|
||||
{/if}
|
||||
{#each $mxRecords as record}
|
||||
<tr>
|
||||
<td>{record.Mx}</td>
|
||||
<td>{record.Preference}</td>
|
||||
<td>{record.Hdr.Name}</td>
|
||||
<td>{record.Hdr.Ttl}</td>
|
||||
<td>
|
||||
<ActionMenu data={record} edit={t => console.log(t)} remove={t => console.log(t)} />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>A/AAAA Record</h2>
|
||||
<table class="action-table" aria-label="List of Domains A/AAAA Record">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<th>IP Address</th>
|
||||
<th>TTL</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if $aRecords.length === 0 && $aaaaRecords.length === 0}
|
||||
<tr class="empty-row"><td colspan="7">No items to display</td></tr>
|
||||
{/if}
|
||||
{#each $aRecords as record}
|
||||
<tr>
|
||||
<td>{record.Hdr.Name}</td>
|
||||
<td>{record.A}</td>
|
||||
<td>{record.Hdr.Ttl}</td>
|
||||
<td>
|
||||
<ActionMenu data={record} edit={t => console.log(t)} remove={t => console.log(t)} />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#each $aaaaRecords as record}
|
||||
<tr>
|
||||
<td>{record.Hdr.Name}</td>
|
||||
<td>{record.AAAA}</td>
|
||||
<td>{record.Hdr.Ttl}</td>
|
||||
<td>
|
||||
<ActionMenu data={record} edit={t => console.log(t)} remove={t => console.log(t)} />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>CNAME Record</h2>
|
||||
<table class="action-table" aria-label="List of Domains CNAME Record">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<th>Aliases to</th>
|
||||
<th>TTL</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if $cnameRecords.length === 0}
|
||||
<tr class="empty-row"><td colspan="7">No items to display</td></tr>
|
||||
{/if}
|
||||
{#each $cnameRecords as record}
|
||||
<tr>
|
||||
<td>{record.Hdr.Name}</td>
|
||||
<td>{record.Target}</td>
|
||||
<td>{record.Hdr.Ttl}</td>
|
||||
<td>
|
||||
<ActionMenu data={record} edit={t => console.log(t)} remove={t => console.log(t)} />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>TXT Record</h2>
|
||||
<table class="action-table" aria-label="List of Domains TXT Record">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<th>Value</th>
|
||||
<th>TTL</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if $txtRecords.length === 0}
|
||||
<tr class="empty-row"><td colspan="7">No items to display</td></tr>
|
||||
{/if}
|
||||
{#each $txtRecords as record}
|
||||
<tr>
|
||||
<td>{record.Hdr.Name}</td>
|
||||
<td>{record.Txt.join("\n")}</td>
|
||||
<td>{record.Hdr.Ttl}</td>
|
||||
<td>
|
||||
<ActionMenu data={record} edit={t => console.log(t)} remove={t => console.log(t)} />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>SRV Record</h2>
|
||||
<table class="action-table" aria-label="List of Domains SRV Record">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Priority</th>
|
||||
<th>Weight</th>
|
||||
<th>Port</th>
|
||||
<th>Target</th>
|
||||
<th>TTL</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if $srvRecords.length === 0}
|
||||
<tr class="empty-row"><td colspan="7">No items to display</td></tr>
|
||||
{/if}
|
||||
{#each $srvRecords as record}
|
||||
<tr>
|
||||
<td>{record.Hdr.Name}</td>
|
||||
<td>{record.Priority}</td>
|
||||
<td>{record.Weight}</td>
|
||||
<td>{record.Port}</td>
|
||||
<td>{record.Target}</td>
|
||||
<td>{record.Hdr.Ttl}</td>
|
||||
<td>
|
||||
<ActionMenu data={record} edit={t => console.log(t)} remove={t => console.log(t)} />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>CAA Record</h2>
|
||||
<table class="action-table" aria-label="List of Domains CAA Record">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Tag</th>
|
||||
<th>Value</th>
|
||||
<th>TTL</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if $caaRecords.length === 0}
|
||||
<tr class="empty-row"><td colspan="7">No items to display</td></tr>
|
||||
{/if}
|
||||
{#each $caaRecords as record}
|
||||
<tr>
|
||||
<td>{record.Hdr.Name}</td>
|
||||
<td>{record.Tag}</td>
|
||||
<td>{record.Value}</td>
|
||||
<td>{record.Hdr.Ttl}</td>
|
||||
<td>
|
||||
<ActionMenu data={record} edit={t => console.log(t)} remove={t => console.log(t)} />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/await}
|
||||
|
||||
<style lang="scss">
|
||||
@import "../values.scss";
|
||||
|
||||
button.action-menu {
|
||||
@include button-green-highlight;
|
||||
}
|
||||
|
||||
table tbody tr.empty-row td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
||||
.zone-download {
|
||||
@include button-green-invert-box;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,17 +0,0 @@
|
||||
<div class="wrapper">
|
||||
<div style="padding:8px;background-color:#bb7900;">Warning: This is currently still under development</div>
|
||||
|
||||
<div class="button-wrapper">
|
||||
<div><a class="btn-green" href="https://uptime-kuma.1f349.com" target="_blank">Status Dashboard</a></div>
|
||||
<div><a class="btn-green" href="https://sso.1f349.com" target="_blank">SSO Dashboard</a></div>
|
||||
<div><a class="btn-green" href="https://grafana.1f349.com" target="_blank">Grafana</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.button-wrapper {
|
||||
margin: 20px;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
</style>
|
15
src/views/HomeView.svelte
Normal file
@ -0,0 +1,15 @@
|
||||
<div class="wrapper">
|
||||
<div style="padding:8px;background-color:#bb7900;">Warning: This is currently still under development</div>
|
||||
|
||||
<div class="button-wrapper">
|
||||
<!-- nothing yet -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.button-wrapper {
|
||||
margin: 20px;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
</style>
|
@ -1,23 +1,20 @@
|
||||
<script lang="ts">
|
||||
import RedirectCreator from "../components/RedirectCreator.svelte";
|
||||
import RedirectRow from "../components/RedirectRow.svelte";
|
||||
import {redirectEqual} from "../types/target";
|
||||
import {redirectsTable} from "../stores/target";
|
||||
import TargetManagementView from "./TargetManagementView.svelte";
|
||||
|
||||
const apiViolet = import.meta.env.VITE_API_VIOLET;
|
||||
</script>
|
||||
|
||||
<TargetManagementView apiUrl="{apiViolet}/redirect" tableData={redirectsTable} equality={redirectEqual}>
|
||||
<h1>Redirects</h1>
|
||||
|
||||
<TargetManagementView apiUrl="{apiViolet}/redirect">
|
||||
<svelte:fragment slot="headers">
|
||||
<th>Source</th>
|
||||
<th>Destination</th>
|
||||
<th>Flags</th>
|
||||
<th>Code</th>
|
||||
<th>Description</th>
|
||||
<th>Active</th>
|
||||
<th>Option</th>
|
||||
</svelte:fragment>
|
||||
<RedirectRow slot="row" let:value {value} />
|
||||
<RedirectCreator slot="creator" let:make on:make={e => make(e)} />
|
||||
</TargetManagementView>
|
||||
|
@ -1,22 +1,19 @@
|
||||
<script lang="ts">
|
||||
import RouteCreator from "../components/RouteCreator.svelte";
|
||||
import RouteRow from "../components/RouteRow.svelte";
|
||||
import {routeEqual} from "../types/target";
|
||||
import {routesTable} from "../stores/target";
|
||||
import TargetManagementView from "./TargetManagementView.svelte";
|
||||
|
||||
const apiViolet = import.meta.env.VITE_API_VIOLET;
|
||||
</script>
|
||||
|
||||
<TargetManagementView apiUrl="{apiViolet}/route" tableData={routesTable} equality={routeEqual}>
|
||||
<h1>Routes</h1>
|
||||
|
||||
<TargetManagementView apiUrl="{apiViolet}/route">
|
||||
<svelte:fragment slot="headers">
|
||||
<th>Source</th>
|
||||
<th>Destination</th>
|
||||
<th>Flags</th>
|
||||
<th>Description</th>
|
||||
<th>Active</th>
|
||||
<th>Option</th>
|
||||
</svelte:fragment>
|
||||
<RouteRow slot="row" let:value {value} />
|
||||
<RouteCreator slot="creator" let:make on:make={e => make(e)} />
|
||||
</TargetManagementView>
|
||||
|
@ -1,15 +1,23 @@
|
||||
<script lang="ts">
|
||||
import PromiseLike from "../components/PromiseLike.svelte";
|
||||
import PromiseTable from "../components/PromiseTable.svelte";
|
||||
import RemoveIcon from "../icons/Remove.svelte";
|
||||
import {domainOption} from "../stores/domain-option";
|
||||
import {type Site, sitesTable} from "../stores/sites";
|
||||
import {LOGIN} from "../utils/login";
|
||||
import {RestItem, RestTable} from "../utils/rest-table";
|
||||
|
||||
const apiSiteHosting = import.meta.env.VITE_API_SITE_HOSTING;
|
||||
|
||||
let tableKeys: string[] = [];
|
||||
$: tableKeys = Object.entries($sitesTable)
|
||||
.map(x => x[0])
|
||||
.filter(x => domainFilter(x, $domainOption))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
const table = new RestTable<Site>(apiSiteHosting, (item: Site) => item.domain);
|
||||
|
||||
interface Site {
|
||||
domain: string;
|
||||
branches: string[];
|
||||
}
|
||||
|
||||
function rowsDomainFilter(rows: RestItem<Site>[], domain: string): RestItem<Site>[] {
|
||||
return rows.filter(x => domainFilter(x.data.domain, domain));
|
||||
}
|
||||
|
||||
function domainFilter(src: string, domain: string) {
|
||||
if (domain == "*") return true;
|
||||
@ -20,112 +28,101 @@
|
||||
return p.endsWith(domain);
|
||||
}
|
||||
|
||||
let promiseForTable: Promise<void> = reloadTable();
|
||||
|
||||
async function reloadTable(): Promise<void> {
|
||||
let f = await LOGIN.clientRequest(apiSiteHosting, {}, false);
|
||||
if (f.status !== 200) throw new Error("Unexpected status code: " + f.status);
|
||||
let fJson = await f.json();
|
||||
let rows = fJson as Site[];
|
||||
rows.forEach(x => {
|
||||
$sitesTable[x.domain] = x;
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteBranch(site: Site, branch: string) {
|
||||
let f = await LOGIN.clientRequest(
|
||||
apiSiteHosting,
|
||||
{
|
||||
let f = await LOGIN.clientRequest(apiSiteHosting, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({submit: "delete-branch", site: site.domain, branch}),
|
||||
},
|
||||
false,
|
||||
);
|
||||
});
|
||||
if (f.status !== 200) throw new Error("Unexpected status code: " + f.status);
|
||||
promiseForTable = reloadTable();
|
||||
table.reload();
|
||||
}
|
||||
|
||||
async function resetSiteSecret(site: Site) {
|
||||
let f = await LOGIN.clientRequest(
|
||||
apiSiteHosting,
|
||||
{
|
||||
let f = await LOGIN.clientRequest(apiSiteHosting, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({submit: "secret", site: site.domain}),
|
||||
},
|
||||
false,
|
||||
);
|
||||
});
|
||||
if (f.status !== 200) throw new Error("Unexpected status code: " + f.status);
|
||||
let fJson = await f.json();
|
||||
alert("New secret: " + fJson.secret);
|
||||
}
|
||||
|
||||
domainOption.subscribe(() => table.reload());
|
||||
</script>
|
||||
|
||||
<div class="wrapper">
|
||||
<h1>Site Hosting</h1>
|
||||
|
||||
<div style="padding:8px;background-color:#bb7900;">
|
||||
Warning: This is currently still under development, however it DOES send updates to the real server
|
||||
</div>
|
||||
|
||||
<div class="scrolling-area">
|
||||
{#await promiseForTable}
|
||||
<div class="text-padding">
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
{:then}
|
||||
<table class="main-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<PromiseTable value={table}>
|
||||
<tr slot="headers">
|
||||
<th>Domain</th>
|
||||
<th>Branches</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="invert-rows">
|
||||
{#each tableKeys as key (key)}
|
||||
{@const site = $sitesTable[key]}
|
||||
<tr>
|
||||
<td><a href="https://{site.domain}" target="_blank">{site.domain}</a></td>
|
||||
|
||||
<svelte:fragment slot="rows" let:value>
|
||||
{#each rowsDomainFilter(value.rows, $domainOption) as item}
|
||||
<PromiseLike value={item}>
|
||||
<tr slot="loading" class="empty-row">
|
||||
<td colspan="100">
|
||||
<div>Loading...</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr slot="error" let:reason class="empty-row">
|
||||
<td colspan="100">Error loading row for {item.data.domain}: {reason}</td>
|
||||
</tr>
|
||||
|
||||
<tr slot="ok" let:value>
|
||||
<td><a href="https://{value.data.domain}" target="_blank">{value.data.domain}</a></td>
|
||||
<td>
|
||||
<div class="branch-cell">
|
||||
{#each site.branches as branch}
|
||||
{#each value.data.branches as branch}
|
||||
{#if branch == ""}
|
||||
<div><a href="https://{site.domain}/?git_branch=main" target="_blank" class="main-or-master">main or master</a></div>
|
||||
<div><a href="https://{value.data.domain}/?git_branch=main" target="_blank" class="main-or-master">main or master</a></div>
|
||||
{:else}
|
||||
<div><a href="https://{site.domain}/?git_branch={branch}" target="_blank">{branch}</a></div>
|
||||
<div><a href="https://{value.data.domain}/?git_branch={branch}" target="_blank">{branch}</a></div>
|
||||
{/if}
|
||||
<div><button on:click={() => deleteBranch(site, branch)}>Delete Branch</button></div>
|
||||
<div><button class="btn-trash" on:click={() => deleteBranch(value.data, branch)}><RemoveIcon /></button></div>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
<td><button on:click={() => resetSiteSecret(site)}>Reset Secret</button></td>
|
||||
<td><button class="btn-reset-secret" on:click={() => resetSiteSecret(value.data)}>Reset Secret</button></td>
|
||||
</tr>
|
||||
</PromiseLike>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:catch err}
|
||||
<div class="text-padding">
|
||||
<div>Administrator... I hardly know her?</div>
|
||||
<div>{err}</div>
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</PromiseTable>
|
||||
|
||||
<style lang="scss">
|
||||
.main-table {
|
||||
th,
|
||||
td {
|
||||
width: 1%;
|
||||
}
|
||||
}
|
||||
@import "../values.scss";
|
||||
|
||||
.branch-cell {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, auto);
|
||||
justify-content: center;
|
||||
justify-content: left;
|
||||
align-content: center;
|
||||
gap: 8px;
|
||||
|
||||
div a {
|
||||
display: block;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.main-or-master {
|
||||
color: lightgreen;
|
||||
}
|
||||
|
||||
.btn-trash {
|
||||
@include button-red-highlight;
|
||||
}
|
||||
|
||||
.btn-reset-secret {
|
||||
@include button-red-box;
|
||||
}
|
||||
</style>
|
||||
|
@ -5,166 +5,56 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import {LOGIN} from "../utils/login";
|
||||
import {writable, type Writable} from "svelte/store";
|
||||
import type {CSPair} from "../types/cspair";
|
||||
import {domainOption} from "../stores/domain-option";
|
||||
import {type CountStats, tableCountStats} from "../stores/target";
|
||||
import {RestItem, RestTable} from "../utils/rest-table";
|
||||
import PromiseTable from "../components/PromiseTable.svelte";
|
||||
import PromiseLike from "../components/PromiseLike.svelte";
|
||||
|
||||
type T = $$Generic<Target>;
|
||||
|
||||
export let apiUrl: string;
|
||||
export let tableData: Writable<{[key: string]: CSPair<T>}>;
|
||||
export let equality: (a: T | null, b: T | null) => boolean;
|
||||
|
||||
let tableSearch: string = "";
|
||||
let table = new RestTable<T>(apiUrl, (item: T) => item.src);
|
||||
|
||||
let tableKeys: string[] = [];
|
||||
$: tableKeys = Object.entries($tableData)
|
||||
.filter(x => x[1].client != null || x[1].server != null)
|
||||
.map(x => x[0])
|
||||
.filter(x => domainFilter(x, $domainOption))
|
||||
.filter(x => x.includes(tableSearch))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
function rowsDomainFilter(rows: RestItem<T>[], domain: string): RestItem<T>[] {
|
||||
return rows.filter(x => domainFilter(x.data, domain));
|
||||
}
|
||||
|
||||
let rowStats: CountStats = {created: 0, modified: 0, removed: 0};
|
||||
$: rowStats = tableCountStats($tableData, tableKeys, equality);
|
||||
|
||||
function domainFilter(src: string, domain: string) {
|
||||
if (domain == "*") return true;
|
||||
let n = src.indexOf("/");
|
||||
if (n == -1) n = src.length;
|
||||
let p = src.slice(0, n);
|
||||
function domainFilter(item: T, domain: string): boolean {
|
||||
let n = item.src.indexOf("/");
|
||||
if (n == -1) n = item.src.length;
|
||||
let p = item.src.slice(0, n);
|
||||
if (p == domain) return true;
|
||||
return p.endsWith(domain);
|
||||
}
|
||||
|
||||
let promiseForTable: Promise<void> = reloadTable();
|
||||
|
||||
async function reloadTable(): Promise<void> {
|
||||
let f = await LOGIN.clientRequest(apiUrl, {}, false);
|
||||
if (f.status !== 200) throw new Error("Unexpected status code: " + f.status);
|
||||
let fJson = await f.json();
|
||||
|
||||
let rows = fJson as T[];
|
||||
let srcs = new Set(Object.keys($tableData));
|
||||
rows.forEach(x => {
|
||||
$tableData[x.src] = {
|
||||
client: !$tableData[x.src] ? JSON.parse(JSON.stringify(x)) : $tableData[x.src]?.client,
|
||||
server: x,
|
||||
p: Promise.resolve(),
|
||||
};
|
||||
srcs.delete(x.src);
|
||||
});
|
||||
srcs.forEach(x => {
|
||||
$tableData[x].server = null;
|
||||
});
|
||||
}
|
||||
|
||||
interface Savable<T> {
|
||||
type: "del" | "ins";
|
||||
v: CSPair<T>;
|
||||
}
|
||||
|
||||
function saveChanges() {
|
||||
let tableProm = tableKeys
|
||||
.map(x => $tableData[x])
|
||||
.filter(x => x.client != null || x.server != null)
|
||||
.filter(x => !equality(x.client, x.server))
|
||||
.map((x: CSPair<T>): Savable<T> => {
|
||||
if (x.client == null && x.server != null) return {type: "del", v: x};
|
||||
return {type: "ins", v: x};
|
||||
})
|
||||
.sort((a, _) => (a.type === "del" ? -1 : a.type === "ins" ? 1 : 0))
|
||||
.map(x => {
|
||||
x.v.p = LOGIN.clientRequest(
|
||||
apiUrl,
|
||||
{
|
||||
method: x.type == "del" ? "DELETE" : "POST",
|
||||
body: JSON.stringify(x.type == "del" ? {src: (x.v.server as T).src} : x.v.client),
|
||||
},
|
||||
false,
|
||||
).then(x => {
|
||||
if (x.status !== 200) throw new Error("Unexpected status code: " + x.status);
|
||||
});
|
||||
return x.v.p;
|
||||
});
|
||||
|
||||
Promise.all(tableProm)
|
||||
.then(_ => reloadTable())
|
||||
.catch(_ => {
|
||||
alert("Some rows failed to save changes");
|
||||
});
|
||||
}
|
||||
|
||||
export function make(e: CustomEvent<T>) {
|
||||
const x = e.detail as unknown as T;
|
||||
$tableData[x.src] = {client: x, server: $tableData[x.src]?.server, p: Promise.resolve()};
|
||||
tableKeys.push(x.src);
|
||||
tableKeys = tableKeys;
|
||||
}
|
||||
domainOption.subscribe(() => table.reload());
|
||||
</script>
|
||||
|
||||
<div class="wrapper">
|
||||
<div style="padding:8px;background-color:#bb7900;">
|
||||
Warning: This is currently still under development, however it DOES send updates to the real server
|
||||
</div>
|
||||
|
||||
<div id="search-wrapper">
|
||||
<label>
|
||||
Search: <input type="search" name="table-search" bind:value={tableSearch} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="scrolling-area">
|
||||
{#await promiseForTable}
|
||||
<div class="text-padding">
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
{:then}
|
||||
<table class="main-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<PromiseTable value={table}>
|
||||
<tr slot="headers">
|
||||
<slot name="headers" />
|
||||
</tr>
|
||||
<slot name="creator" {make} />
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each tableKeys as src (src)}
|
||||
{#await $tableData[src].p}
|
||||
<tr><td colspan="5">Loading...</td></tr>
|
||||
{:then _}
|
||||
<slot name="row" value={writable($tableData[src])} />
|
||||
{:catch err}
|
||||
<tr><td colspan="5">Error loading row for {src}: {err}</td></tr>
|
||||
{/await}
|
||||
|
||||
<svelte:fragment slot="rows" let:value>
|
||||
{#each rowsDomainFilter(value.rows, $domainOption) as item}
|
||||
<PromiseLike value={item}>
|
||||
<tr slot="loading" class="empty-row">
|
||||
<td colspan="100">
|
||||
<div>Loading...</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr slot="error" let:reason class="empty-row">
|
||||
<td colspan="100">Error loading row for {item.data.src}: {reason}</td>
|
||||
</tr>
|
||||
|
||||
<slot name="row" slot="ok" let:value {value} />
|
||||
</PromiseLike>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:catch err}
|
||||
<div class="text-padding">
|
||||
<div>Administrator... I hardly know her?</div>
|
||||
<div>{err}</div>
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<button class="btn-green" on:click={() => saveChanges()}>Save Changes</button>
|
||||
{#if rowStats.created > 0}
|
||||
<div class="meta-info">{rowStats.created} new row{rowStats.created > 1 ? "s" : ""}</div>
|
||||
{/if}
|
||||
{#if rowStats.modified > 0}
|
||||
<div class="meta-info">{rowStats.modified} unsaved change{rowStats.modified > 1 ? "s" : ""}</div>
|
||||
{/if}
|
||||
{#if rowStats.removed > 0}
|
||||
<div class="meta-info">{rowStats.removed} removed row{rowStats.removed > 1 ? "s" : ""}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#search-wrapper {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
</svelte:fragment>
|
||||
</PromiseTable>
|
||||
|
8
src/vite-env.d.ts
vendored
@ -2,9 +2,17 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
VITE_APP_VERSION: string;
|
||||
VITE_APP_LASTMOD: string;
|
||||
|
||||
VITE_SSO_ORIGIN: string;
|
||||
VITE_OAUTH2_CLIENT_ID: string;
|
||||
VITE_OAUTH2_CLIENT_SCOPE: string;
|
||||
VITE_LOGOUT_PAGE: string;
|
||||
VITE_API_VIOLET: string;
|
||||
VITE_API_ORCHID: string;
|
||||
VITE_API_AZALEA: string;
|
||||
VITE_API_SITE_HOSTING: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
@ -79,7 +79,7 @@ func ssoServer(signer mjwt.Signer) {
|
||||
r.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
corsAccessControl.ServeHTTP(w, r, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"aud":"b5a9a8df-827c-4925-b1c1-1940abcf356b","name":"Test User","picture":"","profile":"http://localhost:9090/user/test-user","sub":"b429562a-20e9-4466-9e8e-bdeb55f2f4a3@localhost","updated_at":1572278406,"website":""}`))
|
||||
w.Write([]byte(`{"aud":"b5a9a8df-827c-4925-b1c1-1940abcf356b","name":"Test User","picture":"https://picsum.photos/id/392/200","profile":"http://localhost:9090/user/test-user","sub":"b429562a-20e9-4466-9e8e-bdeb55f2f4a3@localhost","updated_at":1572278406,"website":""}`))
|
||||
})
|
||||
})
|
||||
log.Println("[SSO Server]", http.ListenAndServe(":9090", r))
|
||||
@ -103,21 +103,29 @@ var serveApiCors = cors.New(cors.Options{
|
||||
})
|
||||
|
||||
func apiServer(verify mjwt.Verifier) {
|
||||
subdomains := []string{
|
||||
"",
|
||||
"www.",
|
||||
"admin.",
|
||||
"staff.",
|
||||
"sso.",
|
||||
"login.",
|
||||
"status.",
|
||||
}
|
||||
|
||||
r := http.NewServeMux()
|
||||
r.Handle("/v1/violet/route", hasPerm(verify, "violet:route", func(rw http.ResponseWriter, req *http.Request) {
|
||||
m := make([]map[string]any, 0, 40)
|
||||
for i := 0; i < 20; i++ {
|
||||
m := make([]map[string]any, 0, len(subdomains)*2)
|
||||
for _, i := range subdomains {
|
||||
m = append(m, map[string]any{
|
||||
"src": uuid.NewString() + ".example.com",
|
||||
"src": i + "example.com",
|
||||
"dst": "127.0.0.1:8080",
|
||||
"desc": "This is a test description",
|
||||
"flags": 181,
|
||||
"active": true,
|
||||
})
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
m = append(m, map[string]any{
|
||||
"src": uuid.NewString() + ".example.org",
|
||||
"src": i + "example.org",
|
||||
"dst": "127.0.0.1:8085",
|
||||
"desc": "This is a test description",
|
||||
"flags": 17,
|
||||
@ -127,20 +135,18 @@ func apiServer(verify mjwt.Verifier) {
|
||||
json.NewEncoder(rw).Encode(m)
|
||||
}))
|
||||
r.Handle("/v1/violet/redirect", hasPerm(verify, "violet:redirect", func(rw http.ResponseWriter, req *http.Request) {
|
||||
m := make([]map[string]any, 0, 40)
|
||||
for i := 0; i < 20; i++ {
|
||||
m := make([]map[string]any, 0, len(subdomains)*2)
|
||||
for _, i := range subdomains {
|
||||
m = append(m, map[string]any{
|
||||
"src": uuid.NewString() + ".example.com",
|
||||
"src": i + "example.com",
|
||||
"dst": "test1.example.com",
|
||||
"desc": "This is a test description",
|
||||
"flags": 1,
|
||||
"code": 308,
|
||||
"active": true,
|
||||
})
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
m = append(m, map[string]any{
|
||||
"src": uuid.NewString() + ".example.org",
|
||||
"src": i + "example.org",
|
||||
"dst": "test2.example.org",
|
||||
"desc": "This is a test description",
|
||||
"flags": 3,
|
||||
@ -151,10 +157,10 @@ func apiServer(verify mjwt.Verifier) {
|
||||
json.NewEncoder(rw).Encode(m)
|
||||
}))
|
||||
r.Handle("/v1/orchid/owned", hasPerm(verify, "orchid:cert", func(rw http.ResponseWriter, req *http.Request) {
|
||||
m := make(map[int]any, 41)
|
||||
for i := 0; i < 20; i++ {
|
||||
u := uuid.NewString()
|
||||
m[i] = map[string]any{
|
||||
m := make([]map[string]any, 0, len(subdomains)*2)
|
||||
for i := 0; i < len(subdomains); i++ {
|
||||
u := subdomains[i] + "example.com"
|
||||
m = append(m, map[string]any{
|
||||
"id": i + 1,
|
||||
"auto_renew": true,
|
||||
"active": true,
|
||||
@ -163,14 +169,12 @@ func apiServer(verify mjwt.Verifier) {
|
||||
"not_after": "2024-02-06T11:52:05Z",
|
||||
"updated_at": "2023-11-08T07:32:08Z",
|
||||
"domains": []string{
|
||||
u + ".example.com",
|
||||
"*." + u + ".example.com",
|
||||
u,
|
||||
"*." + u,
|
||||
},
|
||||
}
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
u := uuid.NewString()
|
||||
m[i+20] = map[string]any{
|
||||
})
|
||||
u = subdomains[i] + "example.org"
|
||||
m = append(m, map[string]any{
|
||||
"id": i + 21,
|
||||
"auto_renew": false,
|
||||
"active": false,
|
||||
@ -179,27 +183,153 @@ func apiServer(verify mjwt.Verifier) {
|
||||
"not_after": "2024-02-06T11:52:05Z",
|
||||
"updated_at": "2023-11-08T07:32:08Z",
|
||||
"domains": []string{
|
||||
u + ".example.org",
|
||||
"*." + u + ".example.org",
|
||||
},
|
||||
}
|
||||
}
|
||||
u := uuid.NewString()
|
||||
m[40] = map[string]any{
|
||||
"id": 41,
|
||||
"auto_renew": false,
|
||||
"active": false,
|
||||
"renewing": false,
|
||||
"renew_failed": true,
|
||||
"not_after": "2024-02-06T11:52:05Z",
|
||||
"updated_at": "2023-11-08T07:32:08Z",
|
||||
"domains": []string{
|
||||
u + ".example.org",
|
||||
"*." + u + ".example.org",
|
||||
u,
|
||||
"*." + u,
|
||||
},
|
||||
})
|
||||
}
|
||||
json.NewEncoder(rw).Encode(m)
|
||||
}))
|
||||
r.Handle("/v1/azalea/domains", hasPerm(verify, "domains:manage", func(rw http.ResponseWriter, req *http.Request) {
|
||||
type Zone struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
json.NewEncoder(rw).Encode([]Zone{
|
||||
{ID: 1, Name: "example.com."},
|
||||
{ID: 2, Name: "example.org."},
|
||||
})
|
||||
}))
|
||||
r.Handle("/v1/azalea/domains/example.com/records", hasPerm(verify, "domains:manage", func(rw http.ResponseWriter, req *http.Request) {
|
||||
fmt.Fprintln(rw, `[
|
||||
{
|
||||
"Hdr": {
|
||||
"Name": "example.com.",
|
||||
"Rrtype": 6,
|
||||
"Class": 1,
|
||||
"Ttl": 300,
|
||||
"Rdlength": 0
|
||||
},
|
||||
"Ns": "ns1.example.com.",
|
||||
"Mbox": "hostmaster.example.com.",
|
||||
"Serial": 2024062001,
|
||||
"Refresh": 7200,
|
||||
"Retry": 1800,
|
||||
"Expire": 1209600,
|
||||
"Minttl": 300
|
||||
},
|
||||
{
|
||||
"Hdr": {
|
||||
"Name": "example.com.",
|
||||
"Rrtype": 2,
|
||||
"Class": 1,
|
||||
"Ttl": 300,
|
||||
"Rdlength": 0
|
||||
},
|
||||
"Ns": "ns1.example.com."
|
||||
},
|
||||
{
|
||||
"Hdr": {
|
||||
"Name": "example.com.",
|
||||
"Rrtype": 2,
|
||||
"Class": 1,
|
||||
"Ttl": 300,
|
||||
"Rdlength": 0
|
||||
},
|
||||
"Ns": "ns2.example.com."
|
||||
},
|
||||
{
|
||||
"Hdr": {
|
||||
"Name": "example.com.",
|
||||
"Rrtype": 2,
|
||||
"Class": 1,
|
||||
"Ttl": 300,
|
||||
"Rdlength": 0
|
||||
},
|
||||
"Ns": "ns3.example.com."
|
||||
},
|
||||
{
|
||||
"Hdr": {
|
||||
"Name": "ns1.example.com.",
|
||||
"Rrtype": 1,
|
||||
"Class": 1,
|
||||
"Ttl": 300,
|
||||
"Rdlength": 0
|
||||
},
|
||||
"A": "10.54.0.1"
|
||||
}
|
||||
]`)
|
||||
}))
|
||||
r.Handle("/v1/azalea/domains/example.org/records", hasPerm(verify, "domains:manage", func(rw http.ResponseWriter, req *http.Request) {
|
||||
fmt.Fprintln(rw, `[
|
||||
{
|
||||
"Hdr": {
|
||||
"Name": "example.org.",
|
||||
"Rrtype": 6,
|
||||
"Class": 1,
|
||||
"Ttl": 300,
|
||||
"Rdlength": 0
|
||||
},
|
||||
"Ns": "ns1.example.com.",
|
||||
"Mbox": "hostmaster.example.com.",
|
||||
"Serial": 2024062001,
|
||||
"Refresh": 7200,
|
||||
"Retry": 1800,
|
||||
"Expire": 1209600,
|
||||
"Minttl": 300
|
||||
},
|
||||
{
|
||||
"Hdr": {
|
||||
"Name": "example.org.",
|
||||
"Rrtype": 2,
|
||||
"Class": 1,
|
||||
"Ttl": 300,
|
||||
"Rdlength": 0
|
||||
},
|
||||
"Ns": "ns1.example.com."
|
||||
},
|
||||
{
|
||||
"Hdr": {
|
||||
"Name": "example.org.",
|
||||
"Rrtype": 2,
|
||||
"Class": 1,
|
||||
"Ttl": 300,
|
||||
"Rdlength": 0
|
||||
},
|
||||
"Ns": "ns2.example.com."
|
||||
},
|
||||
{
|
||||
"Hdr": {
|
||||
"Name": "example.org.",
|
||||
"Rrtype": 2,
|
||||
"Class": 1,
|
||||
"Ttl": 300,
|
||||
"Rdlength": 0
|
||||
},
|
||||
"Ns": "ns3.example.com."
|
||||
},
|
||||
{
|
||||
"Hdr": {
|
||||
"Name": "example.org.",
|
||||
"Rrtype": 1,
|
||||
"Class": 1,
|
||||
"Ttl": 300,
|
||||
"Rdlength": 0
|
||||
},
|
||||
"A": "10.36.0.1"
|
||||
},
|
||||
{
|
||||
"Hdr": {
|
||||
"Name": "example.org.",
|
||||
"Rrtype": 28,
|
||||
"Class": 1,
|
||||
"Ttl": 300,
|
||||
"Rdlength": 0
|
||||
},
|
||||
"AAAA": "2001:db8::15"
|
||||
}
|
||||
]`)
|
||||
}))
|
||||
r.Handle("/v1/sites", hasPerm(verify, "sites:manage", func(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == http.MethodPost {
|
||||
defer req.Body.Close()
|
||||
@ -219,16 +349,14 @@ func apiServer(verify mjwt.Verifier) {
|
||||
}
|
||||
return
|
||||
}
|
||||
m := make([]any, 0, 40)
|
||||
for i := 0; i < 20; i++ {
|
||||
m := make([]any, 0, len(subdomains))
|
||||
for _, i := range subdomains {
|
||||
m = append(m, map[string]any{
|
||||
"domain": uuid.NewString() + ".example.com",
|
||||
"domain": i + "example.com",
|
||||
"branches": []string{"", "beta"},
|
||||
})
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
m = append(m, map[string]any{
|
||||
"domain": uuid.NewString() + ".example.org",
|
||||
"domain": i + "example.org",
|
||||
"branches": []string{"", "alpha"},
|
||||
})
|
||||
}
|
||||
|