fix: wire vault persistence, connection loading, and MobaXterm import to real Go backend
Replace all TODO stubs in frontend stores with real Wails Call.ByName bindings. The app store now calls WraithApp.IsFirstRun/CreateVault/Unlock so vault state persists across launches. The connection store loads from ConnectionService.ListConnections/ListGroups instead of hardcoded mock data. The import dialog calls a new WraithApp.ImportMobaConf method that parses the file, creates groups and connections in SQLite, and stores host keys. ConnectionEditDialog also uses real Go CRUD calls. MainLayout loads connections on mount after vault unlock. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fbd2fd4f80
commit
a1dce82d99
@ -88,6 +88,13 @@
|
|||||||
<p class="text-xs text-[var(--wraith-text-muted)]">
|
<p class="text-xs text-[var(--wraith-text-muted)]">
|
||||||
Passwords are not imported (MobaXTerm uses proprietary encryption).
|
Passwords are not imported (MobaXTerm uses proprietary encryption).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="importError"
|
||||||
|
class="text-sm text-[var(--wraith-accent-red)] bg-[var(--wraith-accent-red)]/10 border border-[var(--wraith-accent-red)]/20 rounded px-3 py-2"
|
||||||
|
>
|
||||||
|
{{ importError }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -116,10 +123,11 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="step === 'preview'"
|
v-if="step === 'preview'"
|
||||||
class="px-3 py-1.5 text-xs text-white bg-[#238636] hover:bg-[#2ea043] rounded transition-colors cursor-pointer"
|
:disabled="importing"
|
||||||
|
class="px-3 py-1.5 text-xs text-white bg-[#238636] hover:bg-[#2ea043] rounded transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
@click="doImport"
|
@click="doImport"
|
||||||
>
|
>
|
||||||
Import
|
{{ importing ? "Importing..." : "Import" }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="step === 'complete'"
|
v-if="step === 'complete'"
|
||||||
@ -136,13 +144,23 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
import { Call } from "@wailsio/runtime";
|
||||||
|
import { useConnectionStore } from "@/stores/connection.store";
|
||||||
|
|
||||||
|
/** Fully qualified Go method name prefix for WraithApp bindings. */
|
||||||
|
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
|
||||||
|
|
||||||
type Step = "select" | "preview" | "complete";
|
type Step = "select" | "preview" | "complete";
|
||||||
|
|
||||||
|
const connectionStore = useConnectionStore();
|
||||||
|
|
||||||
const visible = ref(false);
|
const visible = ref(false);
|
||||||
const step = ref<Step>("select");
|
const step = ref<Step>("select");
|
||||||
const fileName = ref("");
|
const fileName = ref("");
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const fileContent = ref("");
|
||||||
|
const importError = ref<string | null>(null);
|
||||||
|
const importing = ref(false);
|
||||||
|
|
||||||
const preview = ref({
|
const preview = ref({
|
||||||
connections: 0,
|
connections: 0,
|
||||||
@ -155,6 +173,8 @@ function open(): void {
|
|||||||
visible.value = true;
|
visible.value = true;
|
||||||
step.value = "select";
|
step.value = "select";
|
||||||
fileName.value = "";
|
fileName.value = "";
|
||||||
|
fileContent.value = "";
|
||||||
|
importError.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function close(): void {
|
function close(): void {
|
||||||
@ -183,11 +203,10 @@ function handleDrop(event: DragEvent): void {
|
|||||||
async function processFile(file: File): Promise<void> {
|
async function processFile(file: File): Promise<void> {
|
||||||
fileName.value = file.name;
|
fileName.value = file.name;
|
||||||
|
|
||||||
// TODO: Replace with Wails binding — ImporterService.Preview(fileData)
|
|
||||||
// For now, read and mock-parse the file to show a preview
|
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
|
fileContent.value = text;
|
||||||
|
|
||||||
// Simple mock parse to count items
|
// Client-side preview parse to count items for display
|
||||||
const lines = text.split("\n");
|
const lines = text.split("\n");
|
||||||
let groups = 0;
|
let groups = 0;
|
||||||
let connections = 0;
|
let connections = 0;
|
||||||
@ -237,10 +256,21 @@ async function processFile(file: File): Promise<void> {
|
|||||||
step.value = "preview";
|
step.value = "preview";
|
||||||
}
|
}
|
||||||
|
|
||||||
function doImport(): void {
|
async function doImport(): Promise<void> {
|
||||||
// TODO: Replace with Wails binding — ImporterService.Import(fileData)
|
if (!fileContent.value) return;
|
||||||
// For now, just show success
|
importing.value = true;
|
||||||
step.value = "complete";
|
importError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Call.ByName(`${APP}.ImportMobaConf`, fileContent.value);
|
||||||
|
// Refresh connection store so sidebar shows imported connections
|
||||||
|
await connectionStore.loadAll();
|
||||||
|
step.value = "complete";
|
||||||
|
} catch (e) {
|
||||||
|
importError.value = e instanceof Error ? e.message : String(e) || "Import failed";
|
||||||
|
} finally {
|
||||||
|
importing.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ open, close, visible });
|
defineExpose({ open, close, visible });
|
||||||
|
|||||||
@ -165,6 +165,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { useConnectionStore, type Connection } from "@/stores/connection.store";
|
import { useConnectionStore, type Connection } from "@/stores/connection.store";
|
||||||
|
import { Call } from "@wailsio/runtime";
|
||||||
|
|
||||||
|
/** Fully qualified Go method name prefix for ConnectionService bindings. */
|
||||||
|
const CONN = "github.com/vstockwell/wraith/internal/connections.ConnectionService";
|
||||||
|
|
||||||
interface ConnectionForm {
|
interface ConnectionForm {
|
||||||
name: string;
|
name: string;
|
||||||
@ -252,7 +256,7 @@ function close(): void {
|
|||||||
visible.value = false;
|
visible.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function save(): void {
|
async function save(): Promise<void> {
|
||||||
if (!isValid.value) return;
|
if (!isValid.value) return;
|
||||||
|
|
||||||
const tags = tagsInput.value
|
const tags = tagsInput.value
|
||||||
@ -260,32 +264,35 @@ function save(): void {
|
|||||||
.map((t) => t.trim())
|
.map((t) => t.trim())
|
||||||
.filter((t) => t.length > 0);
|
.filter((t) => t.length > 0);
|
||||||
|
|
||||||
if (isEditing.value && editingId.value !== null) {
|
try {
|
||||||
// TODO: Replace with Wails binding — ConnectionService.UpdateConnection(id, input)
|
if (isEditing.value && editingId.value !== null) {
|
||||||
const conn = connectionStore.connections.find((c) => c.id === editingId.value);
|
await Call.ByName(`${CONN}.UpdateConnection`, editingId.value, {
|
||||||
if (conn) {
|
name: form.value.name,
|
||||||
conn.name = form.value.name;
|
hostname: form.value.hostname,
|
||||||
conn.hostname = form.value.hostname;
|
port: form.value.port,
|
||||||
conn.port = form.value.port;
|
groupId: form.value.groupId,
|
||||||
conn.protocol = form.value.protocol;
|
color: form.value.color,
|
||||||
conn.groupId = form.value.groupId ?? conn.groupId;
|
tags,
|
||||||
conn.tags = tags;
|
notes: form.value.notes,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await Call.ByName(`${CONN}.CreateConnection`, {
|
||||||
|
name: form.value.name,
|
||||||
|
hostname: form.value.hostname,
|
||||||
|
port: form.value.port,
|
||||||
|
protocol: form.value.protocol,
|
||||||
|
groupId: form.value.groupId,
|
||||||
|
color: form.value.color,
|
||||||
|
tags,
|
||||||
|
notes: form.value.notes,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
// Refresh connections from backend
|
||||||
// TODO: Replace with Wails binding — ConnectionService.CreateConnection(input)
|
await connectionStore.loadAll();
|
||||||
const newId = Math.max(...connectionStore.connections.map((c) => c.id), 0) + 1;
|
close();
|
||||||
connectionStore.connections.push({
|
} catch (err) {
|
||||||
id: newId,
|
console.error("Failed to save connection:", err);
|
||||||
name: form.value.name,
|
|
||||||
hostname: form.value.hostname,
|
|
||||||
port: form.value.port,
|
|
||||||
protocol: form.value.protocol,
|
|
||||||
groupId: form.value.groupId ?? 1,
|
|
||||||
tags,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ openNew, openEdit, close, visible });
|
defineExpose({ openNew, openEdit, close, visible });
|
||||||
|
|||||||
@ -383,8 +383,10 @@ function handleKeydown(event: KeyboardEvent): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
document.addEventListener("keydown", handleKeydown);
|
document.addEventListener("keydown", handleKeydown);
|
||||||
|
// Load connections and groups from the Go backend after vault unlock
|
||||||
|
await connectionStore.loadAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
import { Call } from "@wailsio/runtime";
|
||||||
|
|
||||||
|
/** Fully qualified Go method name prefix for WraithApp bindings. */
|
||||||
|
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraith application store.
|
* Wraith application store.
|
||||||
* Manages unlock state, first-run detection, and vault operations.
|
* Manages unlock state, first-run detection, and vault operations.
|
||||||
*
|
|
||||||
* Once Wails v3 bindings are generated, the mock calls below will be
|
|
||||||
* replaced with actual WraithApp.IsFirstRun(), CreateVault(), Unlock(), etc.
|
|
||||||
*/
|
*/
|
||||||
export const useAppStore = defineStore("app", () => {
|
export const useAppStore = defineStore("app", () => {
|
||||||
const isUnlocked = ref(false);
|
const isUnlocked = ref(false);
|
||||||
@ -17,8 +18,7 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
/** Check whether the vault has been created before. */
|
/** Check whether the vault has been created before. */
|
||||||
async function checkFirstRun(): Promise<void> {
|
async function checkFirstRun(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// TODO: replace with Wails binding — WraithApp.IsFirstRun()
|
isFirstRun.value = await Call.ByName(`${APP}.IsFirstRun`) as boolean;
|
||||||
isFirstRun.value = true;
|
|
||||||
} catch {
|
} catch {
|
||||||
isFirstRun.value = true;
|
isFirstRun.value = true;
|
||||||
} finally {
|
} finally {
|
||||||
@ -30,12 +30,11 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
async function createVault(password: string): Promise<void> {
|
async function createVault(password: string): Promise<void> {
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
// TODO: replace with Wails binding — WraithApp.CreateVault(password)
|
await Call.ByName(`${APP}.CreateVault`, password);
|
||||||
void password;
|
|
||||||
isFirstRun.value = false;
|
isFirstRun.value = false;
|
||||||
isUnlocked.value = true;
|
isUnlocked.value = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : "Failed to create vault";
|
error.value = e instanceof Error ? e.message : String(e) || "Failed to create vault";
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -44,11 +43,10 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
async function unlock(password: string): Promise<void> {
|
async function unlock(password: string): Promise<void> {
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
// TODO: replace with Wails binding — WraithApp.Unlock(password)
|
await Call.ByName(`${APP}.Unlock`, password);
|
||||||
void password;
|
|
||||||
isUnlocked.value = true;
|
isUnlocked.value = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : "Invalid master password";
|
error.value = e instanceof Error ? e.message : String(e) || "Invalid master password";
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
|
import { Call } from "@wailsio/runtime";
|
||||||
|
|
||||||
|
/** Fully qualified Go method name prefix for ConnectionService bindings. */
|
||||||
|
const CONN = "github.com/vstockwell/wraith/internal/connections.ConnectionService";
|
||||||
|
|
||||||
export interface Connection {
|
export interface Connection {
|
||||||
id: number;
|
id: number;
|
||||||
@ -7,37 +11,35 @@ export interface Connection {
|
|||||||
hostname: string;
|
hostname: string;
|
||||||
port: number;
|
port: number;
|
||||||
protocol: "ssh" | "rdp";
|
protocol: "ssh" | "rdp";
|
||||||
groupId: number;
|
groupId: number | null;
|
||||||
|
credentialId?: number | null;
|
||||||
|
color?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
notes?: string;
|
||||||
|
options?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
lastConnected?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Group {
|
export interface Group {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
parentId: number | null;
|
parentId: number | null;
|
||||||
|
sortOrder?: number;
|
||||||
|
icon?: string;
|
||||||
|
children?: Group[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connection store.
|
* Connection store.
|
||||||
* Manages connections, groups, and search state.
|
* Manages connections, groups, and search state.
|
||||||
*
|
* Loads data from the Go backend via Wails bindings.
|
||||||
* Uses mock data until Wails bindings are connected.
|
|
||||||
*/
|
*/
|
||||||
export const useConnectionStore = defineStore("connection", () => {
|
export const useConnectionStore = defineStore("connection", () => {
|
||||||
const connections = ref<Connection[]>([
|
const connections = ref<Connection[]>([]);
|
||||||
{ id: 1, name: "Asgard", hostname: "192.168.1.4", port: 22, protocol: "ssh", groupId: 1, tags: ["Prod"] },
|
const groups = ref<Group[]>([]);
|
||||||
{ id: 2, name: "Docker", hostname: "155.254.29.221", port: 22, protocol: "ssh", groupId: 1, tags: ["Prod"] },
|
|
||||||
{ id: 3, name: "Predator Mac", hostname: "192.168.1.214", port: 22, protocol: "ssh", groupId: 1 },
|
|
||||||
{ id: 4, name: "CLT-VMHOST01", hostname: "100.64.1.204", port: 3389, protocol: "rdp", groupId: 1 },
|
|
||||||
{ id: 5, name: "ITFlow", hostname: "192.154.253.106", port: 22, protocol: "ssh", groupId: 2 },
|
|
||||||
{ id: 6, name: "Mautic", hostname: "192.154.253.112", port: 22, protocol: "ssh", groupId: 2 },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const groups = ref<Group[]>([
|
|
||||||
{ id: 1, name: "Vantz's Stuff", parentId: null },
|
|
||||||
{ id: 2, name: "MSPNerd", parentId: null },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
|
|
||||||
/** Filter connections by search query. */
|
/** Filter connections by search query. */
|
||||||
@ -70,16 +72,31 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||||||
return connectionsByGroup(groupId).length > 0;
|
return connectionsByGroup(groupId).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load connections from backend (mock for now). */
|
/** Load connections from the Go backend. */
|
||||||
async function loadConnections(): Promise<void> {
|
async function loadConnections(): Promise<void> {
|
||||||
// TODO: replace with Wails binding — ConnectionService.ListConnections()
|
try {
|
||||||
// connections.value = await ConnectionService.ListConnections();
|
const conns = await Call.ByName(`${CONN}.ListConnections`) as Connection[];
|
||||||
|
connections.value = conns || [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load connections:", err);
|
||||||
|
connections.value = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load groups from backend (mock for now). */
|
/** Load groups from the Go backend. */
|
||||||
async function loadGroups(): Promise<void> {
|
async function loadGroups(): Promise<void> {
|
||||||
// TODO: replace with Wails binding — ConnectionService.ListGroups()
|
try {
|
||||||
// groups.value = await ConnectionService.ListGroups();
|
const grps = await Call.ByName(`${CONN}.ListGroups`) as Group[];
|
||||||
|
groups.value = grps || [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load groups:", err);
|
||||||
|
groups.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load both connections and groups from the Go backend. */
|
||||||
|
async function loadAll(): Promise<void> {
|
||||||
|
await Promise.all([loadConnections(), loadGroups()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -91,5 +108,6 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||||||
groupHasResults,
|
groupHasResults,
|
||||||
loadConnections,
|
loadConnections,
|
||||||
loadGroups,
|
loadGroups,
|
||||||
|
loadAll,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/vstockwell/wraith/internal/connections"
|
"github.com/vstockwell/wraith/internal/connections"
|
||||||
"github.com/vstockwell/wraith/internal/credentials"
|
"github.com/vstockwell/wraith/internal/credentials"
|
||||||
"github.com/vstockwell/wraith/internal/db"
|
"github.com/vstockwell/wraith/internal/db"
|
||||||
|
"github.com/vstockwell/wraith/internal/importer"
|
||||||
"github.com/vstockwell/wraith/internal/plugin"
|
"github.com/vstockwell/wraith/internal/plugin"
|
||||||
"github.com/vstockwell/wraith/internal/rdp"
|
"github.com/vstockwell/wraith/internal/rdp"
|
||||||
"github.com/vstockwell/wraith/internal/session"
|
"github.com/vstockwell/wraith/internal/session"
|
||||||
@ -228,3 +229,61 @@ func (a *WraithApp) initCredentials() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ImportMobaConf parses a MobaXTerm .mobaconf file and imports its contents
|
||||||
|
// (groups, connections, host keys) into the database.
|
||||||
|
func (a *WraithApp) ImportMobaConf(fileContent string) (*plugin.ImportResult, error) {
|
||||||
|
imp := &importer.MobaConfImporter{}
|
||||||
|
result, err := imp.Parse([]byte(fileContent))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse mobaconf: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create groups and track name -> ID mapping
|
||||||
|
groupMap := make(map[string]int64)
|
||||||
|
for _, g := range result.Groups {
|
||||||
|
created, err := a.Connections.CreateGroup(g.Name, nil)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("failed to create group during import", "name", g.Name, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groupMap[g.Name] = created.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create connections
|
||||||
|
for _, c := range result.Connections {
|
||||||
|
var groupID *int64
|
||||||
|
if id, ok := groupMap[c.GroupName]; ok {
|
||||||
|
groupID = &id
|
||||||
|
}
|
||||||
|
_, err := a.Connections.CreateConnection(connections.CreateConnectionInput{
|
||||||
|
Name: c.Name,
|
||||||
|
Hostname: c.Hostname,
|
||||||
|
Port: c.Port,
|
||||||
|
Protocol: c.Protocol,
|
||||||
|
GroupID: groupID,
|
||||||
|
Notes: c.Notes,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("failed to create connection during import", "name", c.Name, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store host keys
|
||||||
|
for _, hk := range result.HostKeys {
|
||||||
|
_, err := a.db.Exec(
|
||||||
|
`INSERT OR IGNORE INTO host_keys (hostname, port, key_type, fingerprint) VALUES (?, ?, ?, ?)`,
|
||||||
|
hk.Hostname, hk.Port, hk.KeyType, hk.Fingerprint,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("failed to store host key during import", "hostname", hk.Hostname, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("mobaconf import complete",
|
||||||
|
"groups", len(result.Groups),
|
||||||
|
"connections", len(result.Connections),
|
||||||
|
"hostKeys", len(result.HostKeys),
|
||||||
|
)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user