major update to sub-users creation

This commit is contained in:
arabcoders
2025-04-11 19:09:01 +03:00
parent 85b2476ca5
commit 9e9dfb4868
10 changed files with 1164 additions and 653 deletions

View File

@@ -53,6 +53,7 @@
</a>
<div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/tools/plex_token" @click.native="(e) => changeRoute(e)"
v-if="hasAPISettings">
<span class="icon"><i class="fas fa-key"/></span>
@@ -64,20 +65,8 @@
<span class="icon"><i class="fas fa-users"/></span>
<span>Sub Users</span>
</NuxtLink>
</div>
</div>
<div class="navbar-item has-dropdown">
<a class="navbar-link" @click="(e) => openMenu(e)">
<span class="icon"><i class="fas fa-ellipsis-vertical"/></span>
<span>More</span>
</a>
<div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/console" @click.native="(e) => changeRoute(e)" v-if="hasAPISettings">
<span class="icon"><i class="fas fa-terminal"/></span>
<span>Console</span>
</NuxtLink>
<hr class="navbar-divider">
<NuxtLink class="navbar-item" to="/processes" @click.native="(e) => changeRoute(e)"
v-if="hasAPISettings">
@@ -96,33 +85,19 @@
<span class="icon"><i class="fas fa-file"/></span>
<span>Files Integrity</span>
</NuxtLink>
<hr class="navbar-divider">
<NuxtLink class="navbar-item" to="/events" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-calendar-alt"/></span>
<span>Events</span>
</NuxtLink>
<hr class="navbar-divider">
<NuxtLink class="navbar-item" to="/ignore" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-ban"/></span>
<span>Ignore List</span>
</NuxtLink>
<NuxtLink class="navbar-item" to="/report" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-flag"/></span>
<span>Basic Report</span>
</NuxtLink>
<hr class="navbar-divider">
<NuxtLink class="navbar-item" to="/suppression" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-bug-slash"/></span>
<span>Log Suppression</span>
</NuxtLink>
<NuxtLink class="navbar-item" to="/custom" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-map"/></span>
<span>Custom GUIDs</span>
</NuxtLink>
<hr class="navbar-divider">
@@ -137,6 +112,42 @@
<span class="icon"><i class="fas fa-redo"/></span>
<span>System reset</span>
</NuxtLink>
</div>
</div>
<div class="navbar-item has-dropdown">
<a class="navbar-link" @click="(e) => openMenu(e)">
<span class="icon"><i class="fas fa-ellipsis-vertical"/></span>
<span>More</span>
</a>
<div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/console" @click.native="(e) => changeRoute(e)" v-if="hasAPISettings">
<span class="icon"><i class="fas fa-terminal"/></span>
<span>Console</span>
</NuxtLink>
<hr class="navbar-divider">
<NuxtLink class="navbar-item" to="/events" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-calendar-alt"/></span>
<span>Events</span>
</NuxtLink>
<hr class="navbar-divider">
<NuxtLink class="navbar-item" to="/report" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-flag"/></span>
<span>Basic Report</span>
</NuxtLink>
<hr class="navbar-divider">
<NuxtLink class="navbar-item" to="/custom" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-map"/></span>
<span>Custom GUIDs</span>
</NuxtLink>
</div>
</div>
</div>

View File

@@ -3,28 +3,21 @@
<div class="columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4">
<span class="icon"><i class="fas fa-server"></i></span>
<span class="icon"><i class="fas fa-server"/></span>
Backends
</span>
<div class="is-pulled-right">
<div class="field is-grouped">
<p class="control" v-if="backends && backends.length>0">
<button class="button is-purple" v-tooltip.bottom="'Create sub users backends.'"
@click="navigateTo(makeConsoleCommand('backend:create -B -v', true))"
:disabled="'main' !== api_user">
<span class="icon"><i class="fas fa-users"></i></span>
</button>
</p>
<p class="control">
<button class="button is-primary" v-tooltip.bottom="'Add New Backend'"
@click="toggleForm = !toggleForm" :disabled="isLoading">
<span class="icon"><i class="fas fa-add"></i></span>
<span class="icon"><i class="fas fa-add"/></span>
</button>
</p>
<p class="control">
<button class="button is-info" @click="loadContent" :disabled="isLoading"
:class="{'is-loading':isLoading}">
<span class="icon"><i class="fas fa-sync"></i></span>
<span class="icon"><i class="fas fa-sync"/></span>
</button>
</p>
</div>
@@ -48,18 +41,23 @@
No backends found. Please add new backends to start using the tool. You can add new backend by
<NuxtLink @click="toggleForm=true" v-text="'clicking here'"/>
or by clicking the <span class="icon is-clickable" @click="toggleForm=true"><i
class="fas fa-add"></i></span>
class="fas fa-add"/></span>
button above.
</Message>
</div>
<div class="column is-12">
<div class="content">
<h1 class="title is-4">Tools</h1>
<h1 class="title is-4">
<span class="icon"><i class="fas fa-tools"/></span> Tools
</h1>
<ul>
<li>
<NuxtLink :to="`/tools/plex_token`" v-text="'Validate plex token'"/>
</li>
<li v-if="backends && backends.length>0">
<NuxtLink :to="`/tools/sub_users`" v-text="'Create sub-users'"/>
</li>
</ul>
</div>
</div>
@@ -77,12 +75,12 @@
<div class="control">
<NuxtLink :to="`/backend/${backend.name}/edit?redirect=/backends`"
v-tooltip="'Edit backend settings'">
<span class="icon has-text-warning"><i class="fas fa-cog"></i></span>
<span class="icon has-text-warning"><i class="fas fa-cog"/></span>
</NuxtLink>
</div>
<div class="control">
<NuxtLink :to="`/backend/${backend.name}/delete?redirect=/backends`" v-tooltip="'Delete backend'">
<span class="icon has-text-danger"><i class="fas fa-trash"></i></span>
<span class="icon has-text-danger"><i class="fas fa-trash"/></span>
</NuxtLink>
</div>
</div>
@@ -147,7 +145,7 @@
<div class="card-footer-item">
<NuxtLink :to="api_url + backend.urls.webhook" class="is-info is-light"
@click.prevent="copyUrl(backend)">
<span class="icon"><i class="fas fa-copy"></i></span>
<span class="icon"><i class="fas fa-copy"/></span>
<span class="is-hidden-mobile">Copy Webhook URL</span>
<span class="is-hidden-tablet">Webhook</span>
</NuxtLink>
@@ -166,7 +164,7 @@
</select>
</div>
<div class="icon is-left">
<i class="fas fa-terminal"></i>
<i class="fas fa-terminal"/>
</div>
</div>
</div>
@@ -184,10 +182,6 @@
<li>
<strong>Export</strong> means pushing data from the local database to the backends.
</li>
<li v-if="backends && backends.length>0">
To create sub users backends, click on the <span class="icon has-text-purple"><i class="fas fa-users"/></span>
button.
</li>
</ul>
</Message>
</div>
@@ -241,6 +235,16 @@ const usefulCommands = {
title: "Import this backend metadata.",
command: "state:import -v --metadata-only -u {user} -s {name}",
},
import_debug: {
id: 6,
title: "Run import and save debug log.",
command: "state:import -v --debug -u {user} -s {name} --logfile '/config/{user}@{name}.import.txt'",
},
export_debug: {
id: 7,
title: "Run export and save debug log.",
command: "state:export -v --debug -u {user} -s {name} --logfile '/config/{user}@{name}.export.txt'",
},
}
const forwardCommand = async backend => {

View File

@@ -38,7 +38,7 @@
<input type="text" class="input is-fullwidth" v-model="command"
:placeholder="`system:view ${allEnabled ? 'or $ ls' : ''}`"
list="recent_commands"
autocomplete="off" ref="command_input" @keydown.enter="RunCommand" :disabled="isLoading">
autocomplete="off" ref="commandInput" @keydown.enter="RunCommand" :disabled="isLoading">
<span class="icon is-left"><i class="fas fa-terminal" :class="{'fa-spin':isLoading}"></i></span>
</p>
<p class="control" v-if="!isLoading">
@@ -53,49 +53,50 @@
</p>
</div>
</div>
<p class="help" v-if="hasPrefix">
<span class="icon-text">
<span class="icon has-text-danger"><i class="fas fa-exclamation-triangle"></i></span>
<span>Remove the <code>console</code> or <code>docker exec -ti watchstate console</code> from the
input. You should use the command directly, For example i.e <code>db:list --output
yaml</code></span>
</span>
</p>
<p class="help" v-if="hasPlaceholder">
<span class="icon-text">
<span class="icon has-text-warning"><i class="fas fa-exclamation-circle"></i></span>
<span>The command contains <code>[...]</code> which are considered a placeholder, So, please replace
<code>[...]</code> with the intended value if applicable.</span>
</span>
</p>
</div>
</section>
</div>
</div>
<div class="column is-12" v-if="hasPrefix || hasPlaceholder">
<Message message_class="has-background-warning-90 has-text-dark" title="Warning"
icon="fas fa-exclamation-triangle" v-if="hasPrefix">
<p>Use the command directly, For example i.e. <code>db:list -o yaml</code></p>
</Message>
<Message message_class="has-background-warning-90 has-text-dark" title="Warning"
icon="fas fa-exclamation-triangle" v-if="hasPlaceholder">
<span class="icon has-text-warning"><i class="fas fa-exclamation-circle"></i></span>
<span>The command contains <code>[...]</code> which are considered a placeholder, So, please replace
<code>[...]</code> with the intended value if applicable.</span>
</Message>
</div>
<div class="column is-12">
<Message message_class="has-background-info-90 has-text-dark" :toggle="show_page_tips"
@toggle="show_page_tips = !show_page_tips" :use-toggle="true" title="Tips" icon="fas fa-info-circle">
<ul>
<li>
You don't need to write <code>console</code> or <code>docker exec -ti watchstate console</code> Using
this interface. Use the command followed by the options directly. For example, <code>db:list --output
yaml</code>.
<li>You dont need to type <code>console</code> or run <code>docker exec -ti watchstate console</code> when
using this interface. Just enter the command and options directly. For example: <code>db:list --output
yaml</code>.
</li>
<li>
Clicking close connection does not stop the command. It only stops the output from being displayed. The
command will continue to run until it finishes.
<li>Clicking <strong>Close Connection</strong> only stops the output from being shownit does <em>not</em>
stop the command itself. The command will continue running until it finishes.
</li>
<li>
The majority of the commands will not show any output unless error has occurred or important information
needs to be communicated. Use the <code>-v[v[v]]</code> option to increase verbosity. <code>-v</code>
should be enough for most people, If you are debugging, then use <code>-vv --context</code>.
<li>Most commands wont display anything unless theres an error or important message. Use <code>-v</code>
to see more details. If youre debugging, try <code>-vv --context</code> for even more information.
</li>
<li>
There is an environment variable <code>WS_CONSOLE_ENABLE_ALL</code> that can be set to <code>true</code>
to enable all commands to be run from the console. This is disabled by default.
<li>Theres an environment variable <code>WS_CONSOLE_ENABLE_ALL</code> that you can set to <code>true</code>
to allow all commands to run from the console. Its turned off by default.
</li>
<li>To clear the recent command suggestions, use the <code>clear_ac</code> command.</li>
<li>
The number inside the parentheses is the exit code of the last command. If its <code>0</code>, the
command ran successfully. Any other value usually means something went wrong.
</li>
<li>To clear the recent commands auto-suggestions, you can use the <code>clear_ac</code> command.</li>
</ul>
</Message>
</div>
@@ -139,14 +140,15 @@ const response = ref([])
const command = ref(fromCommand)
const isLoading = ref(false)
const outputConsole = ref()
const command_input = ref()
const commandInput = ref()
const executedCommands = useStorage('executedCommands', [])
const exitCode = ref(0)
const bg_enable = useStorage('bg_enable', true)
const bg_opacity = useStorage('bg_opacity', 0.95)
const hasPrefix = computed(() => command.value.startsWith('console') || command.value.startsWith('docker'))
const hasPlaceholder = computed(() => command.value && command.value.match(/\[.*\]/))
const hasPlaceholder = computed(() => command.value && command.value.match(/\[.*]/))
const show_page_tips = useStorage('show_page_tips', true)
const allEnabled = ref(false)
@@ -155,12 +157,13 @@ const RunCommand = async () => {
const api_url = useStorage('api_url', '')
const api_token = useStorage('api_token', '')
/** @type {string} */
let userCommand = command.value
// -- check if the user command starts with console or docker exec -ti watchstate
if (userCommand.startsWith('console') || userCommand.startsWith('docker')) {
notification('error', 'Warning', 'Please remove the [console] or [docker exec -ti watchstate console] from the command.')
return
notification('info', 'Warning', 'Removing leading prefix command from the input.', 2000)
userCommand = userCommand.replace(/^(console|docker exec -ti watchstate)/i, '')
}
// use regex to check if command contains [...]
@@ -189,7 +192,7 @@ const RunCommand = async () => {
if (userCommand.startsWith('$')) {
if (!allEnabled.value) {
notification('error', 'Error', 'The option to execute all commands is disabled.')
command_input.value.focus()
commandInput.value.focus()
return
}
userCommand = userCommand.slice(1)
@@ -223,11 +226,12 @@ const RunCommand = async () => {
sse = new EventSource(`${api_url.value}${api_path.value}/system/command/${token}?apikey=${api_token.value}`)
if ('' !== command.value) {
terminal.value.writeln(`~ ${userCommand}`)
terminal.value.writeln(`(${exitCode.value}) ~ ${userCommand}`)
}
sse.addEventListener('data', async e => terminal.value.write(JSON.parse(e.data).data))
sse.addEventListener('close', async () => finished())
sse.addEventListener('exit_code', async e => exitCode.value = e.data)
sse.onclose = async () => finished()
sse.onerror = async () => finished()
}
@@ -257,10 +261,12 @@ const finished = async () => {
executedCommands.value.shift()
}
terminal.value.writeln(`\n(${exitCode.value}) ~ `)
command.value = ''
await nextTick()
command_input.value.focus()
commandInput.value.focus()
}
const recentCommands = computed(() => executedCommands.value.reverse().slice(-10))
@@ -276,7 +282,7 @@ const clearOutput = async () => {
if (terminal.value) {
terminal.value ? terminal.value.clear() : ''
}
command_input.value.focus()
commandInput.value.focus()
}
onUnmounted(() => {
@@ -296,7 +302,7 @@ onMounted(async () => {
}
window.addEventListener("resize", reSizeTerminal);
command_input.value.focus()
commandInput.value.focus()
if (!terminal.value) {
terminalFit.value = new FitAddon()
@@ -304,7 +310,6 @@ onMounted(async () => {
fontSize: 16,
fontFamily: "'JetBrains Mono', monospace",
cursorBlink: false,
cursorStyle: 'none',
cols: 108,
rows: 10,
disableStdin: true,

View File

@@ -1,26 +1,26 @@
<template>
<div>
<div class="columns is-multiline">
<div class="columns is-multiline is-mobile">
<div class="column is-12 is-clearfix is-unselectable">
<span id="env_page_title" class="title is-4">
<span class="icon"><i class="fas fa-users"/></span>
Create Sub-users
Sub Users
</span>
<div class="is-pulled-right">
<div class="field is-grouped">
<p class="control">
<button class="button is-purple" v-tooltip.bottom="'Export Association.'" @click="exportMapping">
<button class="button is-purple" v-tooltip.bottom="'Export to mapper.yaml file.'" @click="generateFile">
<span class="icon"><i class="fas fa-file-export"/></span>
</button>
</p>
<p class="control">
<button class="button is-primary" v-tooltip.bottom="'Create new user assoication.'" @click="addNewUser">
<span class="icon"><i class="fas fa-plus"></i></span>
<button class="button is-primary" v-tooltip.bottom="'Create new user association.'" @click="addNewUser">
<span class="icon"><i class="fas fa-plus"/></span>
</button>
</p>
<p class="control">
<button class="button is-info" @click="loadContent" :disabled="isLoading"
:class="{'is-loading':isLoading}">
:class="{ 'is-loading': isLoading }">
<span class="icon"><i class="fas fa-sync"/></span>
</button>
</p>
@@ -28,29 +28,46 @@
</div>
<div class="is-hidden-mobile">
<span class="subtitle">
Drag & Drop the relevant users accounts to form association.
Drag & Drop the relevant users accounts to form association. Read the information section.
<template v-if="expires">The cached users list will expire {{ moment(expires).fromNow() }}</template>
</span>
</div>
</div>
<div class="column is-12">
<h2 class="title is-4">Matched users</h2>
<div class="column is-12" v-if="isLoading">
<Message v-if="isLoading" message_class="is-background-info-90 has-text-dark" icon="fas fa-spinner fa-spin"
title="Loading" message="Loading data. Please wait..."/>
</div>
<div class="column is-12" v-for="(group, index) in matchedUsers" :key="index">
<div class="card">
<header class="card-header is-block">
<div class="control has-icons-left">
<input type="text" class="input is-fullwidth" v-model="group.user" required>
<span class="icon is-left"><i class="fas fa-user"/></span>
</div>
<div class="column is-12" v-if="matched?.length < 1 && !isLoading">
<Message message_class="has-background-danger-90 has-text-dark" icon="fas fa-exclamation-triangle"
title="No matched users.">
<p>
<span class="icon"><i class="fas fa-exclamation-triangle"/></span>
<span>Click on the add button to user group</span>
</p>
</Message>
</div>
<div class="column is-6-tablet is-12-mobile" v-for="(group, index) in matched" :key="index">
<div class="card" :class="{ 'is-success': group.matched.length >= 2, 'is-warning': group.matched.length <= 1 }">
<header class="card-header">
<p class="card-header-title is-centered is-text-overflow">{{ group.user }}</p>
<span class="card-header-icon">
<span class="icon" @click="deleteGroup(index)"><i class="fas fa-trash-can"/></span>
</span>
</header>
<div class="card-content">
<draggable v-model="group.matched" :group="{ name: 'shared', pull: true, put: true }" animation="150"
item-key="id">
:move="checkBackend" item-key="id">
<template #item="{ element }">
<div class="draggable-item">
<span>{{ element.backend }}@{{ element.username }}</span>
<span>
{{ element.backend }}@{{ element.username }}
<span v-if="!isSameName(element.real_name, element.username)">
( <u>{{ element.real_name }}</u> )
</span>
</span>
</div>
</template>
</draggable>
@@ -58,26 +75,30 @@
</div>
</div>
<div class="column is-12">
<h2 class="title is-4">Users with no association.</h2>
</div>
<div class="column is-12">
<div class="card">
<div class="column is-12" v-if="!isLoading">
<div class="card is-danger">
<header class="card-header is-block">
<p class="card-header-title is-text-overflow">Users with no association.</p>
<p class="card-header-title is-centered is-text-overflow has-text-danger">
<span class="icon"><i class="fas fa-exclamation-triangle"/></span>
Unmatched Users
</p>
</header>
<div class="card-content">
<draggable v-model="unmatched" :group="{ name: 'shared', pull: true, put: true }" animation="150"
item-key="id">
:move="checkBackend" item-key="id">
<template #item="{ element }">
<div class="draggable-item">
<span>{{ element.backend }}@{{ element.username }}</span>
<span>
{{ element.backend }}@{{ element.username }}
<span v-if="!isSameName(element.real_name, element.username)">
( <u>{{ element.real_name }}</u> )
</span>
</span>
</div>
</template>
</draggable>
</div>
<div v-if="unmatched?.length <1">
<div v-if="unmatched?.length < 1">
<Message message_class="has-background-success-90 has-text-dark" icon="fas fa-check-circle">
<p>
<span class="icon"><i class="fas fa-check"/></span>
@@ -87,106 +108,348 @@
</div>
</div>
</div>
<div class="column is-12" v-if="!isLoading">
<div class="box">
<h1 class="title is-4">
Action form
</h1>
<div class="field" v-if="hasUsers">
<div class="control">
<input id="recreate" type="checkbox" class="switch is-success" v-model="recreate">
<label for="recreate" class="has-text-danger">
Delete current local sub-users data, and re-create them.
</label>
</div>
</div>
<div class="field">
<div class="control">
<input id="backup" type="checkbox" class="switch is-success" v-model="backup">
<label for="backup">Create initial backup for each sub-user remote backend data.</label>
</div>
</div>
<div class="field">
<div class="control">
<input id="no_save" type="checkbox" class="switch is-danger" v-model="noSave">
<label for="no_save">Do not save mapper.</label>
</div>
</div>
<div class="field">
<div class="control">
<input id="verbose" type="checkbox" class="switch is-info" v-model="verbose">
<label for="verbose">Show more indepth logs.</label>
</div>
</div>
<div class="field">
<div class="control">
<input id="dry_run" type="checkbox" class="switch is-info" v-model="dryRun">
<label for="dry_run">Dry-run do not make changes.</label>
</div>
</div>
<div class="field is-fullwidth is-grouped">
<div class="control is-expanded">
<button class="button is-fullwidth is-warning" @click="saveMap">
<span class="icon"><i class="fas fa-save"/></span>
<span>Save mapping</span>
</button>
</div>
<div class="control is-expanded">
<button class="button is-fullwidth" @click="createUsers"
:class="{'is-primary': !dryRun && !recreate, 'is-info':dryRun, 'is-danger': !dryRun && recreate}">
<span class="icon"><i class="fas fa-users"/></span>
<span v-if="!dryRun">
<span v-if="recreate || !hasUsers">
{{ recreate ? 'Re-create' : 'Create' }} sub-users
</span>
<span v-if="!recreate && hasUsers">Update sub-users</span>
</span>
<span v-else>
Test create sub-users
<span v-if="hasUsers">(Safe operation)</span>
</span>
</button>
</div>
</div>
</div>
</div>
<div class="column is-12">
<Message message_class="has-background-info-90 has-text-dark" title="Information" icon="fas fa-info-circle">
<ul>
<li>This page lets you guide the system in matching sub-users across different backends.</li>
<li>
When you click <code>Create sub-users</code>, your mapping will be uploadedunless youve selected <code>Do
not save mapper</code>. Based on your choice, the system will either delete and recreate the local
sub-users, or try to update the existing ones.
</li>
<li class="has-text-danger is-bold">
Warning: If you choose not to delete the existing local sub-users and the matching changes for any reason,
you may end up with duplicate users. We strongly recommend deleting the current local sub-users.
</li>
<li>
Clicking <code>Save mapping</code> will only save your current mapping to the system. It will
<strong>not</strong> create any sub-users.
</li>
<li>
Clicking the <i class="fas fa-file-export"></i> icon will download the current mapping as a YAML file. You
can review and manually upload it to the system later if needed.
</li>
<li>
Users in the <b>Not matched</b> group arent currently linked to any others and likely wont be matched
automatically.
</li>
<li>
Each user group must have at least two users to be considered a valid group.
</li>
<li>
You can drag and drop users from the <b>Not matched</b> group into any other group to manually associate
them.
</li>
<li>
A user group can only include <b>one</b> user from <b>each</b> backend. If you try to add a second user
from the same backend, an error will be shown.
</li>
<li>
The display name format is: <code>backend_name@normalized_name (real_username)</code>. The <code>(real_username)</code>
part only appears if its different from the <code>normalized_name</code>.
</li>
<li>
There is a 5-minute cache when retrieving users from the API, so the data you see might be slightly out of
date. This is to prevent overwhelming external APIs with requests and to have better response times.
</li>
</ul>
</Message>
</div>
</div>
</div>
</template>
<script setup>
import moment from 'moment'
import {makeConsoleCommand, parse_api_response} from "~/utils/index.js";
import {notification} from '~/utils/index'
const data = {
"matched": [
{
"user": "user1",
"matched": [
{
"id": "u1a", "backend": "backend_name1", "username": "user_u1a"
},
{
"id": "u1b", "backend": "backend_name2", "username": "user_u1b"
},
{
"id": "u1c", "backend": "backend_name3", "username": "user_u1c"
}
]
},
{
"user": "user2",
"matched": [
{
"id": "u2a", "backend": "backend_name1", "username": "user_u2a"
},
{
"id": "u2b", "backend": "backend_name2", "username": "user_u2b"
},
{
"id": "u2c", "backend": "backend_name3", "username": "user_u2c"
}
]
},
{
"user": "user3",
"matched": [
{
"id": "u3a", "backend": "backend_name1", "username": "user_u3a"
},
{
"id": "u3b", "backend": "backend_name2", "username": "user_u3b"
},
{
"id": "u3c", "backend": "backend_name3", "username": "user_u3c"
}
]
}
],
"unmatched": [
{
"id": "u4a", "backend": "backend_name1", "username": "user_u4a"
},
{
"id": "u4b", "backend": "backend_name2", "username": "user_u4b"
},
{
"id": "u4c", "backend": "backend_name3", "username": "user_u4c"
}
]
const matched = ref([])
const unmatched = ref([])
const isLoading = ref(false)
const toastIsVisible = ref(false)
const recreate = ref(false)
const backup = ref(false)
const noSave = ref(false)
const dryRun = ref(false)
const hasUsers = ref(false)
const verbose = ref(false)
const expires = ref()
const addNewUser = () => {
const newUserName = `User group #${matched.value.length + 1}`
matched.value.push({user: newUserName, matched: []})
}
const matchedUsers = ref(data.matched)
const unmatched = ref(data.unmatched)
const loadContent = async () => {
if (matched.value.length > 0) {
if (!confirm('Reloading will remove all modifications. Are you sure?')) {
return
}
}
// Function to add a new matched group with a default name
const addNewUser = () => {
const newUserName = 'user ' + (matchedUsers.value.length + 1)
matchedUsers.value.push({
user: newUserName,
matched: []
matched.value = []
unmatched.value = []
isLoading.value = true
try {
const response = await request('/backends/mapper')
const json = await response.json()
if (useRoute().name !== 'tools-sub_users') {
return
}
matched.value = json.matched
unmatched.value = json.unmatched
recreate.value = json.has_users
backup.value = !json.has_users
hasUsers.value = json.has_users
expires.value = json?.expires
} catch (e) {
notification('error', 'Error', e.message)
} finally {
isLoading.value = false
}
}
const generateFile = async () => {
const filename = 'mapper.yaml'
const data = formatData()
if (!data.map.length) {
notification('error', 'Error', 'No data to export.')
return
}
const response = request(`/system/yaml/${filename}`, {
method: 'POST',
headers: {'Accept': 'text/yaml'},
body: JSON.stringify(data)
})
if ('showSaveFilePicker' in window) {
response.then(async res => {
return res.body.pipeTo(await (await showSaveFilePicker({
suggestedName: `${filename}`
})).createWritable())
})
}
response.then(res => res.blob()).then(blob => {
const fileURL = URL.createObjectURL(blob)
const fileLink = document.createElement('a')
fileLink.href = fileURL
fileLink.download = `${filename}`
fileLink.click()
})
}
const checkBackend = e => {
if (e.draggedContext.list === e.relatedContext.list) {
return true;
}
const isMatchedContainer = matched.value.some(
group => group.matched === e.relatedContext.list
);
if (false === isMatchedContainer) {
return true;
}
const draggedUser = e.draggedContext.element;
const alreadyExists = e.relatedContext.list.some(item => item.backend === draggedUser.backend)
if (true === alreadyExists) {
if (!toastIsVisible.value) {
toastIsVisible.value = true;
nextTick(() => {
notification('error', 'error', `A user from '${draggedUser.backend}' backend, already mapped in this group.`, 3001, {
onClose: () => toastIsVisible.value = false,
})
})
}
return false;
}
return true;
}
const deleteGroup = i => {
const group = matched.value[i]
if (group && group.matched && group.matched.length) {
if (false === confirm(`Delete user group #${i + 1}?, Users will be moved to unmatched`)) {
return
}
unmatched.value.push(...group.matched)
}
nextTick(() => matched.value.splice(i, 1))
}
const saveMap = async (no_toast = false) => {
const data = formatData()
if (!data.map.length) {
if (!no_toast) {
notification('error', 'Error', 'No mapping data to save.')
}
return true
}
try {
const req = await request('/backends/mapper', {
method: 'PUT',
body: JSON.stringify(data)
})
const response = await parse_api_response(req)
if (req.status >= 200 && req.status < 300 && !no_toast) {
notification('success', 'Success', response.info.message)
return true
}
if (!no_toast) {
notification('error', 'Error', `${req.status}: ${response.error.message}`)
}
return false
} catch (e) {
notification('error', 'Error', `Error: ${e.message}`)
}
return false
}
const formatData = () => {
const data = {version: "1.5", map: []}
matched.value.forEach((group, i) => {
const users = {}
group.matched.forEach(user => users[user.backend] = {name: user.username})
if (Object.keys(users).length < 2) {
return
}
data.map.push(users)
})
return data
}
const createUsers = async () => {
if (!noSave.value) {
const state = await saveMap()
if (state === false) {
return
}
}
const command = ['backend:create']
command.push(verbose.value ? '-vvv' : '-vv')
command.push(recreate.value ? '--re-create' : '--run --update')
if (backup.value) {
command.push('--generate-backup')
}
if (dryRun.value) {
command.push('--dry-run')
}
await navigateTo(makeConsoleCommand(command.join(' '), true))
}
const isSameName = (name1, name2) => {
return name1.toLowerCase() === name2.toLowerCase()
}
onMounted(async () => await loadContent())
</script>
<style scoped>
table {
margin-bottom: 1em;
}
th, td {
padding: 8px;
text-align: left;
}
/* Make containers flex so items wrap side by side */
.users-list,
.unmatched-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
border: 1px dashed #ccc;
background-color: #fafafa;
}
/* Let draggable items size to content */
.draggable-item {
display: inline-flex;
align-items: center;

View File

@@ -161,14 +161,19 @@ const ucFirst = (str) => {
* @param {string} title The title of the notification.
* @param {string} text The text of the notification.
* @param {number} duration The duration of the notification.
* @param {object} opts Additional options for the notification.
*
* @returns {void}
*/
const notification = (type, title, text, duration = 3000) => {
const notification = (type, title, text, duration = 3000, opts = {}) => {
let method = '', options = {
timeout: duration,
}
if (opts) {
options = {...options, ...opts}
}
switch (type.toLowerCase()) {
case 'info':
default: