oidc working
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createSpacetimeDBProvider } from "spacetimedb/svelte";
|
||||||
|
import type { ConnectionBuilder } from "spacetimedb";
|
||||||
|
import { getStdbHost, getStdbDbName, handleConnect } from "./config";
|
||||||
|
|
||||||
|
let { builder, children, onCancel }: { builder: ConnectionBuilder<any>, children: any, onCancel?: () => void } = $props();
|
||||||
|
|
||||||
|
// Initialize the provider
|
||||||
|
const db = createSpacetimeDBProvider(builder);
|
||||||
|
|
||||||
|
// Fallback: Also register direct listener on the connection if possible
|
||||||
|
$effect(() => {
|
||||||
|
if ($db.connection) {
|
||||||
|
$db.connection.onConnect((conn: any, identity: any, token: string) => {
|
||||||
|
console.log("InnerProvider: connection.onConnect triggered, syncing state...");
|
||||||
|
handleConnect(conn, identity, token);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch for successful connection and persist state
|
||||||
|
$effect(() => {
|
||||||
|
console.log("InnerProvider State:", {
|
||||||
|
isActive: $db.isActive,
|
||||||
|
hasIdentity: !!$db.identity,
|
||||||
|
hasToken: !!$db.token,
|
||||||
|
hasConn: !!$db.connection
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($db.isActive && $db.identity && $db.connection) {
|
||||||
|
console.log("InnerProvider: Connection valid, syncing state...");
|
||||||
|
handleConnect($db.connection, $db.identity, $db.token || "");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = getStdbHost();
|
||||||
|
const dbName = getStdbDbName();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $db.identity}
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
<div class="login-screen">
|
||||||
|
<div class="login-card" style="text-align: center;">
|
||||||
|
<i class="fas fa-circle-notch fa-spin" style="font-size: 3rem; color: var(--brand); margin-bottom: 20px;"></i>
|
||||||
|
<h1>Connecting to SpacetimeDB...</h1>
|
||||||
|
<p style="color: var(--text-muted); margin-top: 8px; margin-bottom: 24px;">Establishing a secure connection to the chat server.</p>
|
||||||
|
|
||||||
|
<div style="background-color: var(--background-tertiary); padding: 16px; border-radius: 8px; margin-bottom: 24px; text-align: left; font-size: 0.8rem; border: 1px solid var(--background-modifier-accent); display: flex; flex-direction: column; gap: 12px; width: 100%; box-sizing: border-box;">
|
||||||
|
<div class="connection-detail">
|
||||||
|
<div style="color: var(--text-muted); font-weight: 800; text-transform: uppercase; font-size: 0.65rem; margin-bottom: 4px; letter-spacing: 0.05em;">Host</div>
|
||||||
|
<div style="color: var(--text-normal); font-family: var(--font-code); word-break: break-all; line-height: 1.4;">{host}</div>
|
||||||
|
</div>
|
||||||
|
<div class="connection-detail">
|
||||||
|
<div style="color: var(--text-muted); font-weight: 800; text-transform: uppercase; font-size: 0.65rem; margin-bottom: 4px; letter-spacing: 0.05em;">Database</div>
|
||||||
|
<div style="color: var(--text-normal); font-family: var(--font-code); word-break: break-all; line-height: 1.4;">{dbName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if onCancel}
|
||||||
|
<button
|
||||||
|
onclick={onCancel}
|
||||||
|
class="btn-secondary"
|
||||||
|
style="width: 100%;"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -1,57 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createSpacetimeDBProvider } from "spacetimedb/svelte";
|
import { connectionBuilder, handleConnect } from "./config";
|
||||||
import { connectionBuilder, getStdbHost, getStdbDbName } from "./config";
|
|
||||||
import { auth } from "./auth/auth.svelte";
|
import { auth } from "./auth/auth.svelte";
|
||||||
|
import { connectionState } from "./connection.svelte";
|
||||||
|
import InnerProvider from "./InnerProvider.svelte";
|
||||||
|
import type { ConnectionBuilder } from "spacetimedb";
|
||||||
|
|
||||||
let { children, onCancel } = $props<{
|
let { children, onCancel } = $props<{
|
||||||
children: any,
|
children: any,
|
||||||
onCancel?: () => void
|
onCancel?: () => void
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const host = getStdbHost();
|
let builder = $state<ConnectionBuilder<any> | null>(null);
|
||||||
const dbName = getStdbDbName();
|
|
||||||
|
|
||||||
// Initialize SpacetimeDB provider
|
|
||||||
const db = createSpacetimeDBProvider(connectionBuilder(
|
|
||||||
auth.user?.id_token
|
|
||||||
));
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
console.log("InnerSpacetimeProvider: $db.isActive:", $db.isActive);
|
if (!auth.isLoading) {
|
||||||
console.log("InnerSpacetimeProvider: $db.identity:", $db.identity?.toHexString());
|
console.log("InnerSpacetimeProvider: Initializing connection...");
|
||||||
console.log("InnerSpacetimeProvider: $db.token:", $db.token ? "present" : "absent");
|
connectionState.status = "connecting";
|
||||||
|
builder = connectionBuilder(auth.user?.id_token);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $db.identity}
|
{#if builder}
|
||||||
{@render children()}
|
<InnerProvider {builder} {onCancel}>
|
||||||
|
{@render children()}
|
||||||
|
</InnerProvider>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="login-screen">
|
<div class="login-screen">
|
||||||
<div class="login-card" style="text-align: center;">
|
<div class="login-card" style="text-align: center;">
|
||||||
<i class="fas fa-circle-notch fa-spin" style="font-size: 3rem; color: var(--brand); margin-bottom: 20px;"></i>
|
<i class="fas fa-circle-notch fa-spin" style="font-size: 3rem; color: var(--brand); margin-bottom: 20px;"></i>
|
||||||
<h1>Connecting to SpacetimeDB...</h1>
|
<h1>Authenticating...</h1>
|
||||||
<p style="color: var(--text-muted); margin-top: 8px; margin-bottom: 24px;">Establishing a secure connection to the chat server.</p>
|
<p style="color: var(--text-muted); margin-top: 8px;">Waiting for identity provider callback.</p>
|
||||||
|
|
||||||
<div style="background-color: var(--background-tertiary); padding: 16px; border-radius: 8px; margin-bottom: 24px; text-align: left; font-size: 0.8rem; border: 1px solid var(--background-modifier-accent); display: flex; flex-direction: column; gap: 12px; width: 100%; box-sizing: border-box;">
|
|
||||||
<div class="connection-detail">
|
|
||||||
<div style="color: var(--text-muted); font-weight: 800; text-transform: uppercase; font-size: 0.65rem; margin-bottom: 4px; letter-spacing: 0.05em;">Host</div>
|
|
||||||
<div style="color: var(--text-normal); font-family: var(--font-code); word-break: break-all; line-height: 1.4;">{host}</div>
|
|
||||||
</div>
|
|
||||||
<div class="connection-detail">
|
|
||||||
<div style="color: var(--text-muted); font-weight: 800; text-transform: uppercase; font-size: 0.65rem; margin-bottom: 4px; letter-spacing: 0.05em;">Database</div>
|
|
||||||
<div style="color: var(--text-normal); font-family: var(--font-code); word-break: break-all; line-height: 1.4;">{dbName}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if onCancel}
|
|
||||||
<button
|
|
||||||
onclick={onCancel}
|
|
||||||
class="btn-secondary"
|
|
||||||
style="width: 100%;"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -30,14 +30,29 @@
|
|||||||
combinedConnection = `${stdbHost.replace(/^(https?|wss?):\/\//, "")}:${stdbDbName}`;
|
combinedConnection = `${stdbHost.replace(/^(https?|wss?):\/\//, "")}:${stdbDbName}`;
|
||||||
|
|
||||||
const isChanging = localStorage.getItem("zep_changing_server") === "true";
|
const isChanging = localStorage.getItem("zep_changing_server") === "true";
|
||||||
|
const isInCallback = window.location.search.includes("code=") && window.location.search.includes("state=");
|
||||||
|
|
||||||
if (isChanging) {
|
if (isChanging) {
|
||||||
userWantsToConnect = false;
|
userWantsToConnect = false;
|
||||||
} else if (TokenStore.get(stdbHost, stdbDbName)) {
|
} else if (TokenStore.get(stdbHost, stdbDbName) || isInCallback) {
|
||||||
// Auto-connect if we have a token and NOT changing server
|
// Auto-connect ONLY if we have a SpacetimeDB token OR are in a redirect callback.
|
||||||
|
// We don't auto-connect just because of auth.isAuthenticated to prevent logout loops.
|
||||||
userWantsToConnect = true;
|
userWantsToConnect = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ensure we transition to connecting if we just finished an OIDC redirect
|
||||||
|
$effect(() => {
|
||||||
|
if (auth.isAuthenticated && !auth.isLoading) {
|
||||||
|
const isInCallback = window.location.search.includes("code=") && window.location.search.includes("state=");
|
||||||
|
if (isInCallback) {
|
||||||
|
untrack(() => {
|
||||||
|
userWantsToConnect = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Split combined connection if it changes
|
// Split combined connection if it changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (combinedConnection.includes(":")) {
|
if (combinedConnection.includes(":")) {
|
||||||
@@ -59,7 +74,13 @@
|
|||||||
combinedConnection = `${hostPart}:${stdbDbName}`;
|
combinedConnection = `${hostPart}:${stdbDbName}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasStoredToken = $derived(!!TokenStore.get(stdbHost, stdbDbName));
|
let hasStoredToken = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Check for token on mount and when connection params change
|
||||||
|
const _ = combinedConnection;
|
||||||
|
hasStoredToken = !!TokenStore.get(stdbHost, stdbDbName);
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (stdbHost) localStorage.setItem(HOST_KEY, stdbHost);
|
if (stdbHost) localStorage.setItem(HOST_KEY, stdbHost);
|
||||||
|
|||||||
+25
-4
@@ -7,8 +7,8 @@ import { getEnv } from "../env";
|
|||||||
|
|
||||||
// OIDC Configuration - User should replace these with their own provider values
|
// OIDC Configuration - User should replace these with their own provider values
|
||||||
export const oidcConfig: UserManagerSettings = {
|
export const oidcConfig: UserManagerSettings = {
|
||||||
authority: getEnv("VITE_OIDC_AUTHORITY", "https://accounts.google.com"),
|
authority: getEnv("VITE_OIDC_AUTHORITY", "https://sso.adamlamers.com/application/o/truenas-zep/"),
|
||||||
client_id: getEnv("VITE_OIDC_CLIENT_ID", "REPLACE_ME"),
|
client_id: getEnv("VITE_OIDC_CLIENT_ID", "Omb5jIeYY2rJqkxK52qZgLRGejOBCK5Km2BcA5RO"),
|
||||||
redirect_uri: window.location.origin,
|
redirect_uri: window.location.origin,
|
||||||
scope: "openid profile email",
|
scope: "openid profile email",
|
||||||
response_type: "code",
|
response_type: "code",
|
||||||
@@ -18,6 +18,7 @@ class AuthStore {
|
|||||||
#userManager: UserManager;
|
#userManager: UserManager;
|
||||||
#user = $state<User | null | undefined>(null);
|
#user = $state<User | null | undefined>(null);
|
||||||
#isLoading = $state(true);
|
#isLoading = $state(true);
|
||||||
|
#isProcessingCallback = false;
|
||||||
|
|
||||||
constructor(settings: UserManagerSettings) {
|
constructor(settings: UserManagerSettings) {
|
||||||
this.#userManager = new UserManager(settings);
|
this.#userManager = new UserManager(settings);
|
||||||
@@ -27,8 +28,19 @@ class AuthStore {
|
|||||||
window.location.search.includes("code=") &&
|
window.location.search.includes("code=") &&
|
||||||
window.location.search.includes("state=")
|
window.location.search.includes("state=")
|
||||||
) {
|
) {
|
||||||
this.signinCallback();
|
if (!this.#isProcessingCallback) {
|
||||||
|
this.signinCallback();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Not in a callback, safe to clear any stale/redundant OIDC state keys
|
||||||
|
// that might have accumulated from partial attempts
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith("oidc.")) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.#userManager
|
this.#userManager
|
||||||
.getUser()
|
.getUser()
|
||||||
.then((user) => {
|
.then((user) => {
|
||||||
@@ -71,10 +83,19 @@ class AuthStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async signinCallback() {
|
async signinCallback() {
|
||||||
|
if (this.#isProcessingCallback) return;
|
||||||
|
this.#isProcessingCallback = true;
|
||||||
this.#isLoading = true;
|
this.#isLoading = true;
|
||||||
try {
|
try {
|
||||||
const user = await this.#userManager.signinCallback();
|
const user = await this.#userManager.signinCallback();
|
||||||
this.#user = user;
|
this.#user = user;
|
||||||
|
|
||||||
|
// Stage the id_token so SpacetimeDB can find it even if OIDC state is cleared
|
||||||
|
if (user.id_token) {
|
||||||
|
console.log("AuthStore: Staging OIDC id_token for SpacetimeDB handshake");
|
||||||
|
localStorage.setItem("zep_oidc_staging_token", user.id_token);
|
||||||
|
}
|
||||||
|
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Signin callback error:", error);
|
console.error("Signin callback error:", error);
|
||||||
@@ -101,7 +122,7 @@ class AuthStore {
|
|||||||
const keysToRemove: string[] = [];
|
const keysToRemove: string[] = [];
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
const key = localStorage.key(i);
|
const key = localStorage.key(i);
|
||||||
if (key && key.includes("auth_token")) {
|
if (key && (key.includes("stdb_token:") || key.includes("auth_token") || key === "zep_oidc_staging_token")) {
|
||||||
keysToRemove.push(key);
|
keysToRemove.push(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-51
@@ -45,35 +45,20 @@ export const TokenStore = {
|
|||||||
const key = localStorage.key(i);
|
const key = localStorage.key(i);
|
||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
|
|
||||||
let connStr: string | null = null;
|
|
||||||
|
|
||||||
if (key.startsWith("stdb_token:")) {
|
if (key.startsWith("stdb_token:")) {
|
||||||
// New format: stdb_token:protocol://host[:port]:database
|
|
||||||
const parts = key.split(":");
|
const parts = key.split(":");
|
||||||
if (parts.length >= 4) {
|
if (parts.length >= 4) {
|
||||||
const dbName = parts[parts.length - 1];
|
const dbName = parts[parts.length - 1];
|
||||||
const hostParts = parts.slice(2, parts.length - 1);
|
const hostParts = parts.slice(2, parts.length - 1);
|
||||||
const host = hostParts.join(":").replace(/^\/\//, "");
|
const host = hostParts.join(":").replace(/^\/\//, "");
|
||||||
connStr = `${host}:${dbName}`;
|
const connStr = `${host}:${dbName}`;
|
||||||
}
|
|
||||||
} else if (key.endsWith("/auth_token")) {
|
if (!seen.has(connStr)) {
|
||||||
// Legacy format: protocol://host[:port]/database/auth_token
|
connections.push(connStr);
|
||||||
// Example: http://localhost:3000/zep/auth_token
|
seen.add(connStr);
|
||||||
const parts = key.split("/");
|
|
||||||
// parts: ["http:", "", "localhost:3000", "zep", "auth_token"]
|
|
||||||
if (parts.length >= 5) {
|
|
||||||
const dbName = parts[parts.length - 2];
|
|
||||||
const host = parts[2];
|
|
||||||
if (dbName && host) {
|
|
||||||
connStr = `${host}:${dbName}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connStr && !seen.has(connStr)) {
|
|
||||||
connections.push(connStr);
|
|
||||||
seen.add(connStr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return connections;
|
return connections;
|
||||||
}
|
}
|
||||||
@@ -102,6 +87,39 @@ export const stopActiveConnection = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const handleConnect = (conn: DbConnection, identity: any, token: string) => {
|
||||||
|
const host = getStdbHost();
|
||||||
|
const dbName = getStdbDbName();
|
||||||
|
|
||||||
|
console.log(`handleConnect: Connection established! Token received (len: ${token?.length || 0})`);
|
||||||
|
|
||||||
|
_connection = conn;
|
||||||
|
if (token) {
|
||||||
|
console.log("handleConnect: Persisting token to TokenStore...");
|
||||||
|
TokenStore.set(host, dbName, token);
|
||||||
|
|
||||||
|
// Clear staging token now that we have a permanent one
|
||||||
|
localStorage.removeItem("zep_oidc_staging_token");
|
||||||
|
} else {
|
||||||
|
console.warn("handleConnect: No token received from SpacetimeDB!");
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionState.status = "connected";
|
||||||
|
connectionState.hasConnectedOnce = true;
|
||||||
|
connectionState.error = null;
|
||||||
|
|
||||||
|
// Call the auth update reducer to ensure OIDC info is synced
|
||||||
|
setTimeout(() => {
|
||||||
|
if (_connection) {
|
||||||
|
console.log("handleConnect: Requesting auth info update...");
|
||||||
|
// Use the typed reducer call if available
|
||||||
|
if ((_connection.reducers as any).updateAuthInfo) {
|
||||||
|
(_connection.reducers as any).updateAuthInfo({});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
class ConnectionManager {
|
class ConnectionManager {
|
||||||
#retryCount = 0;
|
#retryCount = 0;
|
||||||
#reconnectTimeout: any = null;
|
#reconnectTimeout: any = null;
|
||||||
@@ -197,50 +215,29 @@ export const connectionBuilder = (oidcToken?: string) => {
|
|||||||
const host = normalizeHost(rawHost);
|
const host = normalizeHost(rawHost);
|
||||||
const dbName = getStdbDbName();
|
const dbName = getStdbDbName();
|
||||||
|
|
||||||
console.log(`connectionBuilder: Using host: ${host} (raw: ${rawHost}), database: ${dbName}`);
|
console.log(`connectionBuilder: Creating builder for host: ${host}, database: ${dbName}`);
|
||||||
|
|
||||||
connectionState.status = "connecting";
|
|
||||||
|
|
||||||
if (activeManager) {
|
|
||||||
console.log("connectionBuilder: Stopping previous activeManager");
|
|
||||||
activeManager.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
const manager = new ConnectionManager(host, dbName);
|
|
||||||
activeManager = manager;
|
|
||||||
|
|
||||||
const builder = DbConnection.builder()
|
const builder = DbConnection.builder()
|
||||||
.withUri(host)
|
.withUri(host)
|
||||||
.withDatabaseName(dbName);
|
.withDatabaseName(dbName);
|
||||||
|
|
||||||
const storedToken = TokenStore.get(host, dbName);
|
const storedToken = TokenStore.get(host, dbName);
|
||||||
console.log("connectionBuilder: oidcToken:", oidcToken ? "present" : "absent");
|
const stagingToken = localStorage.getItem("zep_oidc_staging_token");
|
||||||
console.log("connectionBuilder: storedToken:", storedToken ? "present" : "absent");
|
|
||||||
|
console.log("connectionBuilder: tokens -> oidc:", oidcToken ? "yes" : "no", "staging:", stagingToken ? "yes" : "no", "stored:", storedToken ? "yes" : "no");
|
||||||
|
|
||||||
if (oidcToken) {
|
if (oidcToken) {
|
||||||
console.log("connectionBuilder: Calling withToken with oidcToken");
|
|
||||||
builder.withToken(oidcToken);
|
builder.withToken(oidcToken);
|
||||||
|
} else if (stagingToken) {
|
||||||
|
console.log("connectionBuilder: Using staged OIDC token");
|
||||||
|
builder.withToken(stagingToken);
|
||||||
} else if (storedToken) {
|
} else if (storedToken) {
|
||||||
console.log("connectionBuilder: Calling withToken with storedToken");
|
|
||||||
builder.withToken(storedToken);
|
builder.withToken(storedToken);
|
||||||
} else {
|
|
||||||
console.log("connectionBuilder: No token, will connect anonymously");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register our manager's listeners directly on the connection object.
|
builder.onConnect((conn: any, identity: any, token: string) => {
|
||||||
// This ensures they coexist with the Svelte provider's listeners.
|
handleConnect(conn, identity, token);
|
||||||
builder.onConnect((c: any, id: any, token: string) => {
|
});
|
||||||
console.log("connectionBuilder: CONNECTION onConnect TRIGGERED!");
|
|
||||||
manager.onConnect(c as any, id, token);
|
|
||||||
});
|
|
||||||
builder.onDisconnect((_ctx: any, err: any) => {
|
|
||||||
console.log("connectionBuilder: CONNECTION onDisconnect TRIGGERED!", err);
|
|
||||||
manager.onDisconnect();
|
|
||||||
});
|
|
||||||
builder.onConnectError((ctx: any, err: any) => {
|
|
||||||
console.log("connectionBuilder: CONNECTION onConnectError TRIGGERED!", err);
|
|
||||||
manager.onConnectError(ctx, err);
|
|
||||||
});
|
|
||||||
|
|
||||||
return builder;
|
return builder;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user