Реализованы задачи бэклога 1-5 (1я не до конца)
This commit is contained in:
@@ -68,7 +68,6 @@ KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false
|
||||
# (openssl rand -hex 32)
|
||||
KEYCLOAK_OTP_HMAC_KEY=change-me
|
||||
KEYCLOAK_OTP_TTL_SEC=300
|
||||
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS=5
|
||||
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
|
||||
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
|
||||
KEYCLOAK_ADMIN=bootstrap-admin
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Store raw device identifiers and add OTP verification limit.
|
||||
|
||||
Revision ID: 0004_device_otp
|
||||
Revises: 0003_consent_audit
|
||||
Create Date: 2026-07-21
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0004_device_otp"
|
||||
down_revision: str | None = "0003_consent_audit"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.ux_sessions
|
||||
ADD COLUMN IF NOT EXISTS device_id varchar(255)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO han_app.app_settings
|
||||
(setting_key, setting_value, value_type, is_public, description,
|
||||
record_status, updated_at)
|
||||
VALUES
|
||||
('otp.phone.max_verify_attempts', '5', 'integer', false,
|
||||
'Maximum failed verification attempts for one OTP challenge',
|
||||
'A', now())
|
||||
ON CONFLICT (setting_key) DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError("Device and OTP limits migration is forward-only")
|
||||
@@ -103,6 +103,7 @@ class UxSession(Common, Base):
|
||||
platform: Mapped[str] = mapped_column(String(32))
|
||||
app_version: Mapped[str] = mapped_column(String(64))
|
||||
device_id_hash: Mapped[str | None] = mapped_column(String(64))
|
||||
device_id: Mapped[str | None] = mapped_column(String(255))
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
|
||||
@@ -415,7 +415,7 @@ async def ready(request: Request, db: Session):
|
||||
try:
|
||||
await db.execute(text("SELECT 1"))
|
||||
revision = await db.scalar(text("SELECT version_num FROM han_app.alembic_version LIMIT 1"))
|
||||
if revision != "0003_consent_audit":
|
||||
if revision != "0004_device_otp":
|
||||
raise RuntimeError("unexpected database revision")
|
||||
await load_settings(db)
|
||||
components["postgres"] = "ok"
|
||||
@@ -979,6 +979,7 @@ async def otp_settings(
|
||||
return JSONResponse({
|
||||
"max_send_attempts_per_24h": settings.integer("otp.phone.max_send_attempts_per_24h"),
|
||||
"min_seconds_between_attempts": settings.integer("otp.phone.min_seconds_between_attempts"),
|
||||
"max_verify_attempts": settings.integer("otp.phone.max_verify_attempts"),
|
||||
"version": settings.version,
|
||||
"cache_ttl_seconds": 60,
|
||||
}, headers=headers)
|
||||
|
||||
@@ -63,6 +63,7 @@ REQUIRED_SETTINGS = {
|
||||
"auth.password.enabled",
|
||||
"otp.phone.max_send_attempts_per_24h",
|
||||
"otp.phone.min_seconds_between_attempts",
|
||||
"otp.phone.max_verify_attempts",
|
||||
"operator.call.phone",
|
||||
"consent.personal_data.required",
|
||||
"consent.personal_data.document_url",
|
||||
@@ -182,9 +183,7 @@ def device_snapshot(device: Any) -> dict[str, str | None]:
|
||||
return {
|
||||
"platform": device.platform,
|
||||
"app_version": device.app_version,
|
||||
"device_id_hash": (
|
||||
hashlib.sha256(device.device_id.encode()).hexdigest() if device.device_id else None
|
||||
),
|
||||
"device_id": device.device_id,
|
||||
}
|
||||
|
||||
|
||||
@@ -235,7 +234,7 @@ async def bootstrap(
|
||||
user_id = (await session.execute(statement)).scalar_one()
|
||||
await session.execute(
|
||||
insert(ClientProfile)
|
||||
.values(id=uuid.uuid4(), user_id=user_id)
|
||||
.values(id=uuid.uuid4(), user_id=user_id, russian_phone=principal.phone_number)
|
||||
.on_conflict_do_nothing(index_elements=[ClientProfile.user_id])
|
||||
)
|
||||
for consent_type in ("personal_data", "user_agreement", "marketing"):
|
||||
@@ -300,7 +299,7 @@ async def record_consents(
|
||||
device = {
|
||||
"platform": ux_session.platform,
|
||||
"app_version": ux_session.app_version,
|
||||
"device_id_hash": ux_session.device_id_hash,
|
||||
"device_id": ux_session.device_id,
|
||||
}
|
||||
now = datetime.now(UTC)
|
||||
versions: dict[str, str] = {}
|
||||
@@ -366,7 +365,7 @@ async def start_session(
|
||||
start_reason=body.start_reason,
|
||||
platform=body.device.platform,
|
||||
app_version=body.device.app_version,
|
||||
device_id_hash=device["device_id_hash"],
|
||||
device_id=device["device_id"],
|
||||
started_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -68,7 +68,7 @@ def test_trace_id_uses_valid_w3c_header_and_generates_fallback() -> None:
|
||||
int(fallback, 16)
|
||||
|
||||
|
||||
def test_user_agent_and_device_are_hashed_without_raw_identifiers() -> None:
|
||||
def test_user_agent_is_hashed_and_device_parameters_are_preserved() -> None:
|
||||
agent = "Example Browser/1.0"
|
||||
assert user_agent_hash(request(user_agent=agent)) == hashlib.sha256(agent.encode()).hexdigest()
|
||||
snapshot = device_snapshot(
|
||||
@@ -77,9 +77,8 @@ def test_user_agent_and_device_are_hashed_without_raw_identifiers() -> None:
|
||||
assert snapshot == {
|
||||
"platform": "web",
|
||||
"app_version": "1.2.3",
|
||||
"device_id_hash": hashlib.sha256(b"raw-device-id").hexdigest(),
|
||||
"device_id": "raw-device-id",
|
||||
}
|
||||
assert "raw-device-id" not in str(snapshot)
|
||||
|
||||
|
||||
def test_audit_copies_request_context_and_bounded_metadata() -> None:
|
||||
|
||||
@@ -41,6 +41,11 @@ from app.postgres import create_postgres_engine
|
||||
|
||||
logger = logging.getLogger("bitrix-local-app")
|
||||
|
||||
BITRIX_SENDER_PREFIX = re.compile(
|
||||
r"^\[b\][^\r\n\[]+:\[/b\]\s*(?:\[br\]\s*)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
CONNECTOR_ICON_DATA_URI = "data:image/svg+xml," + quote(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
|
||||
'<path fill="#fff" d="M20 20h15v25h30V20h15v60H65V60H35v20H20z"/>'
|
||||
@@ -222,6 +227,10 @@ def first(value: Any, *paths: tuple[str, ...]) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
def strip_bitrix_sender_prefix(text: str) -> str:
|
||||
return BITRIX_SENDER_PREFIX.sub("", text, count=1)
|
||||
|
||||
|
||||
def normalize_event(payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
event = str(payload.get("event", "")).upper()
|
||||
if event not in {
|
||||
@@ -257,6 +266,7 @@ def normalize_event(payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
text_value = str(
|
||||
first(data, ("MESSAGES", "0", "message", "text"), ("MESSAGE", "TEXT")) or ""
|
||||
)
|
||||
text_value = strip_bitrix_sender_prefix(text_value)
|
||||
files_raw = first(data, ("MESSAGES", "0", "message", "files"), ("MESSAGE", "FILES")) or []
|
||||
if isinstance(files_raw, dict):
|
||||
files_raw = list(files_raw.values())
|
||||
|
||||
@@ -58,12 +58,36 @@ def test_normalize_message_and_finish():
|
||||
assert message["event_type"] == "message.new"
|
||||
assert message["external_chat_id"] == external
|
||||
assert message["bitrix_message_id"] == "86497"
|
||||
assert message["message"]["text"] == "Ответ"
|
||||
closed = normalize_event(
|
||||
{"event": "ONIMCONNECTORDIALOGFINISH", "data": {"external_chat_id": external}}
|
||||
)
|
||||
assert closed["event_type"] == "dialog.closed"
|
||||
|
||||
|
||||
def test_normalize_message_removes_bitrix_sender_prefix():
|
||||
external = str(uuid.uuid4())
|
||||
message = normalize_event(
|
||||
{
|
||||
"event": "ONIMCONNECTORMESSAGEADD",
|
||||
"data": {
|
||||
"MESSAGES": [
|
||||
{
|
||||
"im": {"message_id": 86498},
|
||||
"chat": {"id": external},
|
||||
"message": {
|
||||
"text": "[b]Антон Пичугин:[/b] [br]опять ты?",
|
||||
"files": [],
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert message["message"]["text"] == "опять ты?"
|
||||
|
||||
|
||||
def test_normalize_bitrix_file_uses_download_url_and_infers_mime_type():
|
||||
external = str(uuid.uuid4())
|
||||
message = normalize_event(
|
||||
|
||||
@@ -4,6 +4,7 @@ settings:
|
||||
auth.password.enabled: {type: boolean, value: false, public: true}
|
||||
otp.phone.max_send_attempts_per_24h: {type: integer, value: 3, public: false}
|
||||
otp.phone.min_seconds_between_attempts: {type: integer, value: 30, public: false}
|
||||
otp.phone.max_verify_attempts: {type: integer, value: 5, public: false}
|
||||
operator.call.phone: {type: string, value: "+74999591007", public: true}
|
||||
consent.personal_data.required: {type: boolean, value: true, public: true}
|
||||
consent.personal_data.document_url: {type: string, value: "https://www.han0107.ru/privacy/persdata-agree-mobile", public: true}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Platform, ScrollView, Text, View } from "react-native";
|
||||
import { useApp } from "../../src/app-context";
|
||||
@@ -23,9 +23,11 @@ export default function ChatScreen() {
|
||||
const { dialogId } = useLocalSearchParams<{ dialogId: string }>();
|
||||
const app = useApp();
|
||||
const client = useQueryClient();
|
||||
const router = useRouter();
|
||||
const [text, setText] = useState("");
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [sending, setSending] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
|
||||
const dialog = useQuery({ queryKey: ["dialog", dialogId], queryFn: () => dialogApi.get(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" });
|
||||
const messages = useQuery({ queryKey: ["messages", dialogId], queryFn: () => dialogApi.messages(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" });
|
||||
@@ -104,6 +106,15 @@ export default function ChatScreen() {
|
||||
} catch (reason) { setError(reason); }
|
||||
};
|
||||
|
||||
const createDialog = async () => {
|
||||
setCreating(true); setError(undefined);
|
||||
try {
|
||||
const created = await dialogApi.create(crypto.randomUUID());
|
||||
router.replace(`/dialogs/${created.dialog_id}`);
|
||||
} catch (reason) { setError(reason); }
|
||||
finally { setCreating(false); }
|
||||
};
|
||||
|
||||
const closed = dialog.data?.status === "closed";
|
||||
return <ScrollView contentContainerStyle={styles.page}>
|
||||
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={app.authStatus === "authenticated" ? () => void app.signOut() : undefined} />
|
||||
@@ -122,7 +133,11 @@ export default function ChatScreen() {
|
||||
</View>)}
|
||||
{!messages.isLoading && !messages.data?.items.length && <Text style={styles.muted}>Сообщений пока нет.</Text>}
|
||||
</View>
|
||||
{closed ? <Text style={styles.muted}>Диалог закрыт и доступен только для чтения.</Text> : <View style={styles.card}>
|
||||
{closed ? <View style={styles.card}>
|
||||
<Text style={styles.muted}>Диалог закрыт и доступен только для чтения.</Text>
|
||||
<Button title={creating ? "Создание…" : "Начать новый диалог"} disabled={creating} onPress={() => void createDialog()} />
|
||||
{error && <ErrorNotice error={error} retry={() => void createDialog()} />}
|
||||
</View> : <View style={styles.card}>
|
||||
<Field label="Новое сообщение" multiline value={text} onChangeText={setText} />
|
||||
<View style={styles.row}>
|
||||
<Button title={sending ? "Отправка…" : "Отправить"} disabled={sending || !text.trim()} onPress={() => void sendText()} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Link } from "expo-router";
|
||||
import React from "react";
|
||||
import { Link, useRouter } from "expo-router";
|
||||
import React, { useState } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { useApp } from "../../src/app-context";
|
||||
import { dialogApi } from "../../src/services";
|
||||
@@ -15,6 +15,9 @@ const statusLabels = {
|
||||
|
||||
export default function DialogsScreen() {
|
||||
const app = useApp();
|
||||
const router = useRouter();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<unknown>();
|
||||
const dialogs = useInfiniteQuery({
|
||||
queryKey: ["dialogs"],
|
||||
queryFn: ({ pageParam }) => dialogApi.list(pageParam),
|
||||
@@ -22,6 +25,20 @@ export default function DialogsScreen() {
|
||||
getNextPageParam: (page) => page.next_cursor ?? undefined,
|
||||
enabled: app.authStatus === "authenticated",
|
||||
});
|
||||
|
||||
const createDialog = async () => {
|
||||
setCreating(true);
|
||||
setCreateError(undefined);
|
||||
try {
|
||||
const dialog = await dialogApi.create(crypto.randomUUID());
|
||||
router.push(`/dialogs/${dialog.dialog_id}`);
|
||||
} catch (error) {
|
||||
setCreateError(error);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (app.authStatus !== "authenticated") return <ScrollView contentContainerStyle={styles.page}>
|
||||
<Header status={app.authStatus} realtime={app.realtimeState} />
|
||||
<Text accessibilityRole="header" style={styles.title}>История диалогов</Text>
|
||||
@@ -33,6 +50,8 @@ export default function DialogsScreen() {
|
||||
return <ScrollView contentContainerStyle={styles.page}>
|
||||
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={() => void app.signOut()} />
|
||||
<Text accessibilityRole="header" style={styles.title}>История диалогов</Text>
|
||||
<Button title={creating ? "Создание…" : "Новый диалог"} disabled={creating} onPress={() => void createDialog()} />
|
||||
{createError && <ErrorNotice error={createError} retry={() => void createDialog()} />}
|
||||
{dialogs.isLoading && <Loading />}
|
||||
{dialogs.error && <ErrorNotice error={dialogs.error} retry={() => void dialogs.refetch()} />}
|
||||
{!dialogs.isLoading && !items.length && <Text style={styles.muted}>Диалогов пока нет.</Text>}
|
||||
|
||||
@@ -62,7 +62,6 @@ services:
|
||||
KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:?KEYCLOAK_OTP_MOCK_CODE is required}
|
||||
KEYCLOAK_OTP_HMAC_KEY: ${KEYCLOAK_OTP_HMAC_KEY:?KEYCLOAK_OTP_HMAC_KEY is required}
|
||||
KEYCLOAK_OTP_TTL_SEC: ${KEYCLOAK_OTP_TTL_SEC:-300}
|
||||
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS: ${KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS:-5}
|
||||
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC: ${KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC:-300}
|
||||
KEYCLOAK_SETTINGS_BRIDGE_URL: ${KEYCLOAK_SETTINGS_BRIDGE_URL:-http://api-backend:8000/internal/settings/v1/otp}
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN:?KEYCLOAK_SETTINGS_BRIDGE_TOKEN is required}
|
||||
|
||||
@@ -8,7 +8,6 @@ KEYCLOAK_OTP_MOCK_ENABLED=true
|
||||
KEYCLOAK_OTP_MOCK_CODE=replace-with-random-6-plus-character-secret
|
||||
KEYCLOAK_OTP_HMAC_KEY=replace-with-at-least-32-random-bytes
|
||||
KEYCLOAK_OTP_TTL_SEC=300
|
||||
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS=5
|
||||
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
|
||||
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=replace-with-service-token
|
||||
|
||||
@@ -24,7 +24,6 @@ services:
|
||||
KEYCLOAK_OTP_MOCK_CODE: ${KEYCLOAK_OTP_MOCK_CODE:?mock code is required}
|
||||
KEYCLOAK_OTP_HMAC_KEY: ${KEYCLOAK_OTP_HMAC_KEY:?OTP HMAC key is required}
|
||||
KEYCLOAK_OTP_TTL_SEC: ${KEYCLOAK_OTP_TTL_SEC:-300}
|
||||
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS: ${KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS:-5}
|
||||
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC: ${KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC:-300}
|
||||
KEYCLOAK_SETTINGS_BRIDGE_URL: ${KEYCLOAK_SETTINGS_BRIDGE_URL:-http://api-backend:8000/internal/settings/v1/otp}
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN: ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN:?settings bridge token is required}
|
||||
|
||||
@@ -8,7 +8,6 @@ final class Config {
|
||||
static final String MOCK_CODE = required("KEYCLOAK_OTP_MOCK_CODE");
|
||||
static final byte[] HMAC_KEY = required("KEYCLOAK_OTP_HMAC_KEY").getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
static final Duration OTP_TTL = Duration.ofSeconds(integer("KEYCLOAK_OTP_TTL_SEC", 300, 30, 900));
|
||||
static final int MAX_VERIFY_ATTEMPTS = integer("KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS", 5, 1, 10);
|
||||
static final Duration SETTINGS_MAX_STALE = Duration.ofSeconds(
|
||||
integer("KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC", 300, 30, 3600));
|
||||
static final URI SETTINGS_URL = URI.create(env("KEYCLOAK_SETTINGS_BRIDGE_URL",
|
||||
|
||||
@@ -58,7 +58,7 @@ final class OtpStore {
|
||||
challenge.createdAt = now;
|
||||
challenge.expiresAt = now.plus(Config.OTP_TTL);
|
||||
challenge.verifyAttempts = 0;
|
||||
challenge.maxVerifyAttempts = Config.MAX_VERIFY_ATTEMPTS;
|
||||
challenge.maxVerifyAttempts = limits.maxVerifyAttempts();
|
||||
challenge.settingsVersion = limits.version();
|
||||
challenge.providerId = "mock-" + Crypto.randomId();
|
||||
challenge.providerStatus = "accepted";
|
||||
|
||||
@@ -17,7 +17,7 @@ final class SettingsBridge {
|
||||
.connectTimeout(Duration.ofSeconds(2)).build();
|
||||
private static volatile Cached cached;
|
||||
|
||||
record Limits(int maxSendsPer24h, int minSecondsBetween, String version) {}
|
||||
record Limits(int maxSendsPer24h, int minSecondsBetween, int maxVerifyAttempts, String version) {}
|
||||
private record Cached(Limits limits, Instant fetchedAt, Instant refreshAfter, String etag) {}
|
||||
|
||||
private SettingsBridge() {}
|
||||
@@ -44,12 +44,14 @@ final class SettingsBridge {
|
||||
if (response.statusCode() != 200) throw new IllegalStateException("settings_http_" + response.statusCode());
|
||||
int max = integer(response.body(), "max_send_attempts_per_24h");
|
||||
int minimum = integer(response.body(), "min_seconds_between_attempts");
|
||||
int maxVerify = integer(response.body(), "max_verify_attempts");
|
||||
int ttl = integer(response.body(), "cache_ttl_seconds");
|
||||
String version = string(response.body(), "version");
|
||||
if (max < 1 || max > 100 || minimum < 0 || minimum > 86400 || ttl < 1 || ttl > 3600) {
|
||||
if (max < 1 || max > 100 || minimum < 0 || minimum > 86400
|
||||
|| maxVerify < 1 || maxVerify > 10 || ttl < 1 || ttl > 3600) {
|
||||
throw new IllegalStateException("settings_invalid_range");
|
||||
}
|
||||
Limits limits = new Limits(max, minimum, version);
|
||||
Limits limits = new Limits(max, minimum, maxVerify, version);
|
||||
cached = new Cached(limits, now, now.plusSeconds(ttl),
|
||||
response.headers().firstValue("ETag").orElse(null));
|
||||
return limits;
|
||||
|
||||
@@ -213,7 +213,6 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
"KEYCLOAK_OTP_MOCK_CODE",
|
||||
"KEYCLOAK_OTP_HMAC_KEY",
|
||||
"KEYCLOAK_OTP_TTL_SEC",
|
||||
"KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS",
|
||||
"KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC",
|
||||
"KEYCLOAK_SETTINGS_BRIDGE_URL",
|
||||
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN",
|
||||
|
||||
Reference in New Issue
Block a user