mirror of
https://github.com/aljazceru/react-native-pubky.git
synced 2025-12-17 22:54:29 +01:00
Add base project
Implement auth method
This commit is contained in:
24
rust/pubky/examples/authz/3rd-party-app/.gitignore
vendored
Normal file
24
rust/pubky/examples/authz/3rd-party-app/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
26
rust/pubky/examples/authz/3rd-party-app/index.html
Normal file
26
rust/pubky/examples/authz/3rd-party-app/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/pubky.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Pubky Auth Demo</title>
|
||||
<link rel="stylesheet" href="./src/index.css" />
|
||||
<script type="module">
|
||||
import "@synonymdev/pubky"
|
||||
</script>
|
||||
<script type="module" src="/src/pubky-auth-widget.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<pubky-auth-widget
|
||||
relay="https://demo.httprelay.io/link/"
|
||||
caps="/pub/pubky.app/:rw,/pub/example.com/nested:rw"
|
||||
>
|
||||
</pubky-auth-widget>
|
||||
|
||||
<main>
|
||||
<h1>Third Party app!</h1>
|
||||
<p>this is a demo for using Pubky Auth in an unhosted (no backend) app.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
1146
rust/pubky/examples/authz/3rd-party-app/package-lock.json
generated
Normal file
1146
rust/pubky/examples/authz/3rd-party-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
rust/pubky/examples/authz/3rd-party-app/package.json
Normal file
20
rust/pubky/examples/authz/3rd-party-app/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "pubky-auth-3rd-party",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "npm run dev",
|
||||
"dev": "vite --host --open",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@synonymdev/pubky": "file:../../../pubky/pkg",
|
||||
"lit": "^3.2.0",
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
||||
1
rust/pubky/examples/authz/3rd-party-app/public/pubky.svg
Normal file
1
rust/pubky/examples/authz/3rd-party-app/public/pubky.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" viewBox="0 0 1511 1511" width="1511" height="1511"><style>.a{fill:#fff}</style><path d="m269 0h973c148.6 0 269 120.4 269 269v973c0 148.6-120.4 269-269 269h-973c-148.6 0-269-120.4-269-269v-973c0-148.6 120.4-269 269-269z"/><path fill-rule="evenodd" class="a" d="m630.1 1064.3l14.9-59.6c50.5-27.7 90.8-71.7 113.7-124.8-47.3 51.2-115.2 83.3-190.5 83.3-51.9 0-100.1-15.1-140.4-41.2l-39.8 142.3c0 0-199.3 0-200 0l162.4-619.3h210.5l-0.1 0.1q3.7-0.1 7.4-0.1c77.6 0 147.2 34 194.7 88l22-88h201.9l-46.9 180.8 183.7-180.8h248.8l-322.8 332.6 223.9 286.7h-290.8l-116.6-154.6-40.3 154.6c0 0-195 0-195.7 0z"/></svg>
|
||||
|
After Width: | Height: | Size: 655 B |
48
rust/pubky/examples/authz/3rd-party-app/src/index.css
Normal file
48
rust/pubky/examples/authz/3rd-party-app/src/index.css
Normal file
@@ -0,0 +1,48 @@
|
||||
:root {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
color: white;
|
||||
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
transparent 20%,
|
||||
#151718 20%,
|
||||
#151718 80%,
|
||||
transparent 80%,
|
||||
transparent
|
||||
),
|
||||
radial-gradient(
|
||||
circle,
|
||||
transparent 20%,
|
||||
#151718 20%,
|
||||
#151718 80%,
|
||||
transparent 80%,
|
||||
transparent
|
||||
)
|
||||
25px 25px,
|
||||
linear-gradient(#202020 1px, transparent 2px) 0 -1px,
|
||||
linear-gradient(90deg, #202020 1px, #151718 2px) -1px 0;
|
||||
background-size: 50px 50px, 50px 50px, 25px 25px, 25px 25px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 20rem;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: bold;
|
||||
font-size: 3.2rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 80rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
336
rust/pubky/examples/authz/3rd-party-app/src/pubky-auth-widget.js
Normal file
336
rust/pubky/examples/authz/3rd-party-app/src/pubky-auth-widget.js
Normal file
@@ -0,0 +1,336 @@
|
||||
import { LitElement, css, html } from 'lit'
|
||||
import { createRef, ref } from 'lit/directives/ref.js';
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
const DEFAULT_HTTP_RELAY = "https://demo.httprelay.io/link"
|
||||
|
||||
/**
|
||||
*/
|
||||
export class PubkyAuthWidget extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
/**
|
||||
* Relay endpoint for the widget to receive Pubky AuthTokens
|
||||
*
|
||||
* Internally, a random channel ID will be generated and a
|
||||
* GET request made for `${realy url}/${channelID}`
|
||||
*
|
||||
* If no relay is passed, the widget will use a default relay:
|
||||
* https://demo.httprelay.io/link
|
||||
*/
|
||||
relay: { type: String },
|
||||
/**
|
||||
* Capabilities requested or this application encoded as a string.
|
||||
*/
|
||||
caps: { type: String },
|
||||
/**
|
||||
* Widget's state (open or closed)
|
||||
*/
|
||||
open: { type: Boolean },
|
||||
/**
|
||||
* Show "copied to clipboard" note
|
||||
*/
|
||||
showCopied: { type: Boolean },
|
||||
}
|
||||
}
|
||||
|
||||
canvasRef = createRef();
|
||||
|
||||
constructor() {
|
||||
if (!window.pubky) {
|
||||
throw new Error("window.pubky is unavailable, make sure to import `@synonymdev/pubky` before this web component.")
|
||||
}
|
||||
|
||||
super()
|
||||
|
||||
this.open = false;
|
||||
|
||||
// TODO: allow using mainnet
|
||||
/** @type {import("@synonymdev/pubky").PubkyClient} */
|
||||
this.pubkyClient = window.pubky.PubkyClient.testnet();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback()
|
||||
|
||||
let [url, promise] = this.pubkyClient.authRequest(this.relay || DEFAULT_HTTP_RELAY, this.caps);
|
||||
|
||||
promise.then(session => {
|
||||
console.log({ id: session.pubky().z32(), capabilities: session.capabilities() })
|
||||
alert(`Successfully signed in to ${session.pubky().z32()} with capabilities: ${session.capabilities().join(",")}`)
|
||||
}).catch(e => {
|
||||
console.error(e)
|
||||
})
|
||||
|
||||
// let keypair = pubky.Keypair.random();
|
||||
// const Homeserver = pubky.PublicKey.from('8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo')
|
||||
// this.pubkyClient.signup(keypair, Homeserver).then(() => {
|
||||
// this.pubkyClient.sendAuthToken(keypair, url)
|
||||
// })
|
||||
|
||||
this.authUrl = url
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
id="widget"
|
||||
class=${this.open ? "open" : ""}
|
||||
>
|
||||
<button class="header" @click=${this._switchOpen}>
|
||||
<svg id="pubky-icon" version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1511 1511"><path fill-rule="evenodd" d="m636.3 1066.7 14.9-59.7c50.5-27.7 90.8-71.7 113.7-124.9-47.3 51.3-115.2 83.4-190.6 83.4-51.9 0-100.1-15.1-140.5-41.2L394 1066.7H193.9L356.4 447H567l-.1.1q3.7-.1 7.4-.1c77.7 0 147.3 34 194.8 88l22-88h202.1l-47 180.9L1130 447h249l-323 332.8 224 286.9H989L872.4 912l-40.3 154.7H636.3z" style="fill:#fff"/></svg>
|
||||
<span class="text">
|
||||
Pubky Auth
|
||||
</span>
|
||||
</button>
|
||||
<div class="line"></div>
|
||||
<div id="widget-content">
|
||||
<p>Scan or copy Pubky auth URL</p>
|
||||
<div class="card">
|
||||
<canvas id="qr" ${ref(this._setQr)}></canvas>
|
||||
</div>
|
||||
<button class="card url" @click=${this._copyToClipboard}>
|
||||
<div class="copied ${this.showCopied ? "show" : ""}">Copied to Clipboard</div>
|
||||
<p>${this.authUrl}</p>
|
||||
<svg width="14" height="16" viewBox="0 0 14 16" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="10" height="12" rx="2" fill="white"></rect><rect x="3" y="3" width="10" height="12" rx="2" fill="white" stroke="#3B3B3B"></rect></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
_setQr(canvas) {
|
||||
QRCode.toCanvas(canvas, this.authUrl, {
|
||||
margin: 2,
|
||||
scale: 8,
|
||||
|
||||
color: {
|
||||
light: '#fff',
|
||||
dark: '#000',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_switchOpen() {
|
||||
this.open = !this.open
|
||||
}
|
||||
|
||||
async _copyToClipboard() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.authUrl);
|
||||
this.showCopied = true;
|
||||
setTimeout(() => { this.showCopied = false }, 1000)
|
||||
} catch (error) {
|
||||
console.error('Failed to copy text: ', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
id="widget"
|
||||
class=${this.open ? "open" : ""}
|
||||
>
|
||||
<button class="header" @click=${this._switchOpen}>
|
||||
<svg id="pubky-icon" version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1511 1511"><path fill-rule="evenodd" d="m636.3 1066.7 14.9-59.7c50.5-27.7 90.8-71.7 113.7-124.9-47.3 51.3-115.2 83.4-190.6 83.4-51.9 0-100.1-15.1-140.5-41.2L394 1066.7H193.9L356.4 447H567l-.1.1q3.7-.1 7.4-.1c77.7 0 147.3 34 194.8 88l22-88h202.1l-47 180.9L1130 447h249l-323 332.8 224 286.9H989L872.4 912l-40.3 154.7H636.3z" style="fill:#fff"/></svg>
|
||||
<span class="text">
|
||||
Pubky Auth
|
||||
</span>
|
||||
</button>
|
||||
<div class="line"></div>
|
||||
<div id="widget-content">
|
||||
<p>Scan or copy Pubky auth URL</p>
|
||||
<div class="card">
|
||||
<canvas id="qr" ${ref(this._setQr)}></canvas>
|
||||
</div>
|
||||
<button class="card url" @click=${this._copyToClipboard}>
|
||||
<div class="copied ${this.showCopied ? "show" : ""}">Copied to Clipboard</div>
|
||||
<p>${this.authUrl}</p>
|
||||
<svg width="14" height="16" viewBox="0 0 14 16" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="10" height="12" rx="2" fill="white"></rect><rect x="3" y="3" width="10" height="12" rx="2" fill="white" stroke="#3B3B3B"></rect></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:host {
|
||||
--full-width: 22rem;
|
||||
--full-height: 31rem;
|
||||
--header-height: 3rem;
|
||||
--closed-width: 3rem;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/** End reset */
|
||||
|
||||
#widget {
|
||||
color: white;
|
||||
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
|
||||
background-color:red;
|
||||
|
||||
z-index: 99999;
|
||||
overflow: hidden;
|
||||
background: rgba(43, 43, 43, .7372549019607844);
|
||||
border: 1px solid #3c3c3c;
|
||||
box-shadow: 0 10px 34px -10px rgba(236, 243, 222, .05);
|
||||
border-radius: 8px;
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
width: var(--closed-width);
|
||||
height: var(--header-height);
|
||||
|
||||
will-change: height,width;
|
||||
transition-property: height, width;
|
||||
transition-duration: 80ms;
|
||||
transition-timing-function: ease-in;
|
||||
}
|
||||
|
||||
#widget.open{
|
||||
width: var(--full-width);
|
||||
height: var(--full-height);
|
||||
}
|
||||
|
||||
.header {
|
||||
height: var(--header-height);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#widget
|
||||
.header .text {
|
||||
display: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
#widget.open
|
||||
.header .text {
|
||||
display: block
|
||||
}
|
||||
|
||||
#widget.open
|
||||
.header {
|
||||
width: var(--full-width);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#pubky-icon {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#widget.open
|
||||
#pubky-icon {
|
||||
width: var(--header-height);
|
||||
height: 74%;
|
||||
}
|
||||
|
||||
#widget-content{
|
||||
width: var(--full-width);
|
||||
padding: 0 1rem
|
||||
}
|
||||
|
||||
#widget p {
|
||||
font-size: .87rem;
|
||||
line-height: 1rem;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
opacity: .5;
|
||||
|
||||
/* Fix flash wrap in open animation */
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
#qr {
|
||||
width: 18em !important;
|
||||
height: 18em !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
background: #3b3b3b;
|
||||
border-radius: 5px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card.url {
|
||||
padding: .625rem;
|
||||
justify-content: space-between;
|
||||
max-width:100%;
|
||||
}
|
||||
|
||||
.url p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
line-height: 1!important;
|
||||
width: 93%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
.line {
|
||||
height: 1px;
|
||||
background-color: #3b3b3b;
|
||||
flex: 1 1;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.copied {
|
||||
will-change: opacity;
|
||||
transition-property: opacity;
|
||||
transition-duration: 80ms;
|
||||
transition-timing-function: ease-in;
|
||||
|
||||
opacity: 0;
|
||||
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -1.6rem;
|
||||
font-size: 0.9em;
|
||||
background: rgb(43 43 43 / 98%);
|
||||
padding: .5rem;
|
||||
border-radius: .3rem;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.copied.show {
|
||||
opacity:1
|
||||
}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('pubky-auth-widget', PubkyAuthWidget)
|
||||
29
rust/pubky/examples/authz/README.md
Normal file
29
rust/pubky/examples/authz/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Pubky Auth Example
|
||||
|
||||
This example shows 3rd party authorization in Pubky.
|
||||
|
||||
It consists of 2 parts:
|
||||
|
||||
1. [3rd party app](./3rd-party-app): A web component showing the how to implement a Pubky Auth widget.
|
||||
2. [Authenticator CLI](./authenticator): A CLI showing the authenticator (key chain) asking user for consent and generating the AuthToken.
|
||||
|
||||
## Usage
|
||||
|
||||
First you need to be running a local testnet Homeserver, in the root of this repo run
|
||||
|
||||
```bash
|
||||
cargo run --bin pubky_homeserver -- --testnet
|
||||
```
|
||||
|
||||
Run the frontend of the 3rd party app
|
||||
|
||||
```bash
|
||||
cd ./3rd-party-app
|
||||
npm start
|
||||
```
|
||||
|
||||
Copy the Pubky Auth URL from the frontend.
|
||||
|
||||
Finally run the CLI to paste the Pubky Auth in.
|
||||
|
||||
You should see the frontend reacting by showing the success of authorization and session details.
|
||||
1906
rust/pubky/examples/authz/authenticator/Cargo.lock
generated
Normal file
1906
rust/pubky/examples/authz/authenticator/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
rust/pubky/examples/authz/authenticator/Cargo.toml
Normal file
14
rust/pubky/examples/authz/authenticator/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "authenticator"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.86"
|
||||
base64 = "0.22.1"
|
||||
clap = { version = "4.5.16", features = ["derive"] }
|
||||
pubky = { version = "0.1.0", path = "../../../pubky" }
|
||||
pubky-common = { version = "0.1.0", path = "../../../pubky-common" }
|
||||
rpassword = "7.3.1"
|
||||
tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] }
|
||||
url = "2.5.2"
|
||||
80
rust/pubky/examples/authz/authenticator/src/main.rs
Normal file
80
rust/pubky/examples/authz/authenticator/src/main.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use pubky::PubkyClient;
|
||||
use std::path::PathBuf;
|
||||
use url::Url;
|
||||
|
||||
use pubky_common::{capabilities::Capability, crypto::PublicKey};
|
||||
|
||||
/// local testnet HOMESERVER
|
||||
const HOMESERVER: &str = "8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo";
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about, long_about = None)]
|
||||
struct Cli {
|
||||
/// Path to a recovery_file of the Pubky you want to sign in with
|
||||
recovery_file: PathBuf,
|
||||
|
||||
/// Pubky Auth url
|
||||
url: Url,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let recovery_file = std::fs::read(&cli.recovery_file)?;
|
||||
println!("\nSuccessfully opened recovery file");
|
||||
|
||||
let url = cli.url;
|
||||
|
||||
let caps = url
|
||||
.query_pairs()
|
||||
.filter_map(|(key, value)| {
|
||||
if key == "caps" {
|
||||
return Some(
|
||||
value
|
||||
.split(',')
|
||||
.filter_map(|cap| Capability::try_from(cap).ok())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
};
|
||||
None
|
||||
})
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
|
||||
if !caps.is_empty() {
|
||||
println!("\nRequired Capabilities:");
|
||||
}
|
||||
|
||||
for cap in &caps {
|
||||
println!(" {} : {:?}", cap.scope, cap.actions);
|
||||
}
|
||||
|
||||
// === Consent form ===
|
||||
|
||||
println!("\nEnter your recovery_file's passphrase to confirm:");
|
||||
let passphrase = rpassword::read_password()?;
|
||||
|
||||
let keypair = pubky_common::recovery_file::decrypt_recovery_file(&recovery_file, &passphrase)?;
|
||||
|
||||
println!("Successfully decrypted recovery file...");
|
||||
println!("PublicKey: {}", keypair.public_key());
|
||||
|
||||
let client = PubkyClient::testnet();
|
||||
|
||||
// For the purposes of this demo, we need to make sure
|
||||
// the user has an account on the local homeserver.
|
||||
if client.signin(&keypair).await.is_err() {
|
||||
client
|
||||
.signup(&keypair, &PublicKey::try_from(HOMESERVER).unwrap())
|
||||
.await?;
|
||||
};
|
||||
|
||||
println!("Sending AuthToken to the 3rd party app...");
|
||||
|
||||
client.send_auth_token(&keypair, url).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user