feat(trae): 粘贴回调导入 JWT + 浏览器回调服务器自动回传
- Trae 表单新增「登录回调链接」粘贴框,支持整条 http://127.0.0.1:18080/authorize?... 解析并填入 JWT - 「在浏览器中打开登录入口」改为启动本地 18081 回调服务器 + 浏览器登录,登录完自动保存(Sub2API 同款)
This commit is contained in:
parent
df84aa94ab
commit
deea17bf6e
@ -16,8 +16,8 @@ android {
|
||||
applicationId = "com.rainy.token"
|
||||
minSdk = 31
|
||||
targetSdk = 35
|
||||
versionCode = 35
|
||||
versionName = "1.7.15"
|
||||
versionCode = 36
|
||||
versionName = "1.7.16"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
package com.rainy.token.data.proxy
|
||||
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.routing
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* 本地登录回调服务器:在 App 内起一个 127.0.0.1 端口,
|
||||
* 手机浏览器完成 Trae OAuth 后跳回 `http://127.0.0.1:<port>/authorize?...`,
|
||||
* 这里把整个回调 URL 交给 ViewModel 解析并保存凭据(Sub2API 同款体验)。
|
||||
*
|
||||
* 只绑 loopback;授权完成/超时/退出登录页时调用 [stop] 释放端口。
|
||||
*/
|
||||
object LoginCallbackServer {
|
||||
|
||||
private const val PATH = "/authorize"
|
||||
|
||||
private var server: EmbeddedServer<*, *>? = null
|
||||
private var pending: CompletableDeferred<String>? = null
|
||||
|
||||
/** 启动(已启动则只重置等待任务)。返回是否可用。 */
|
||||
@Synchronized
|
||||
fun start(port: Int): Boolean {
|
||||
if (server != null) {
|
||||
pending = CompletableDeferred()
|
||||
return true
|
||||
}
|
||||
pending = CompletableDeferred()
|
||||
return try {
|
||||
val engine = embeddedServer(CIO, host = "127.0.0.1", port = port) {
|
||||
routing {
|
||||
get(PATH) {
|
||||
val callbackUrl = "http://127.0.0.1:$port" + call.request.local.uri
|
||||
pending?.complete(callbackUrl)
|
||||
call.respondText(
|
||||
"登录成功,可以返回雨晴Token了。",
|
||||
ContentType.Text.Html
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
engine.start(wait = false)
|
||||
server = engine
|
||||
true
|
||||
} catch (t: Throwable) {
|
||||
server = null
|
||||
pending = null
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** 等待登录回调;[timeoutMs] 内没等到返回 null。 */
|
||||
suspend fun await(timeoutMs: Long): String? {
|
||||
val deferred = pending ?: return null
|
||||
return withTimeoutOrNull(timeoutMs) { deferred.await() }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
runCatching { server?.stop(gracePeriodMillis = 200, timeoutMillis = 1000) }
|
||||
server = null
|
||||
pending = null
|
||||
}
|
||||
}
|
||||
@ -153,10 +153,13 @@ fun CredentialEditScreen(
|
||||
if (service == ServiceType.TRAE) {
|
||||
TraeCredentialForm(
|
||||
jwt = uiState.traeJwt,
|
||||
callbackUrl = uiState.traeCallbackUrl,
|
||||
region = uiState.traeRegion,
|
||||
checkinDeviceId = uiState.traeCheckinDeviceId,
|
||||
hasExisting = uiState.hasExisting,
|
||||
onJwtChange = viewModel::updateTraeJwt,
|
||||
onCallbackUrlChange = viewModel::updateTraeCallbackUrl,
|
||||
onImportCallback = { viewModel.importTraeCallbackFromUrl(it) },
|
||||
onRegionChange = viewModel::updateTraeRegion,
|
||||
onCheckinDeviceIdChange = viewModel::updateTraeCheckinDeviceId,
|
||||
onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE, accountId, uiState.traeRegion) },
|
||||
@ -779,10 +782,13 @@ private fun ManualCookieForm(
|
||||
@Composable
|
||||
private fun TraeCredentialForm(
|
||||
jwt: String,
|
||||
callbackUrl: String,
|
||||
region: String,
|
||||
checkinDeviceId: String,
|
||||
hasExisting: Boolean,
|
||||
onJwtChange: (String) -> Unit,
|
||||
onCallbackUrlChange: (String) -> Unit,
|
||||
onImportCallback: (String) -> Unit,
|
||||
onRegionChange: (String) -> Unit,
|
||||
onCheckinDeviceIdChange: (String) -> Unit,
|
||||
onStartInAppLogin: () -> Unit,
|
||||
@ -800,6 +806,22 @@ private fun TraeCredentialForm(
|
||||
) {
|
||||
Text(stringResource(R.string.action_login_in_app))
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = callbackUrl,
|
||||
onValueChange = onCallbackUrlChange,
|
||||
label = { Text(stringResource(R.string.field_trae_callback_url)) },
|
||||
supportingText = { Text(stringResource(R.string.field_trae_callback_hint)) },
|
||||
minLines = 1,
|
||||
maxLines = 3,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = { onImportCallback(callbackUrl) },
|
||||
enabled = callbackUrl.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.action_parse_callback))
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = jwt,
|
||||
onValueChange = onJwtChange,
|
||||
|
||||
@ -26,6 +26,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
@ -299,6 +300,70 @@ class CredentialEditViewModel @Inject constructor(
|
||||
fun updateTraeJwt(value: String) {
|
||||
_uiState.update { it.copy(traeJwt = value) }
|
||||
}
|
||||
fun updateTraeCallbackUrl(value: String) {
|
||||
_uiState.update { it.copy(traeCallbackUrl = value) }
|
||||
}
|
||||
|
||||
/** 解析浏览器登录回调链接,填入 JWT;解析失败给提示。 */
|
||||
fun importTraeCallbackFromUrl(raw: String) {
|
||||
val input = raw.trim()
|
||||
if (input.isEmpty()) {
|
||||
_uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) }
|
||||
return
|
||||
}
|
||||
val parsed = parseTraeCallback(input)
|
||||
if (parsed == null) {
|
||||
_uiState.update { it.copy(message = UiText.Resource(R.string.error_trae_callback_invalid)) }
|
||||
return
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
traeJwt = parsed.first,
|
||||
traeRegion = parsed.second,
|
||||
traeCallbackUrl = input
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTraeCallback(input: String): Pair<String, String>? {
|
||||
val raw = input.trim()
|
||||
val uri = runCatching { android.net.Uri.parse(raw) }.getOrNull() ?: return null
|
||||
fun query(name: String): String? {
|
||||
uri.getQueryParameter(name)?.let { return it }
|
||||
val frag = uri.fragment
|
||||
if (!frag.isNullOrBlank()) {
|
||||
frag.split("&").forEach { pair ->
|
||||
val kv = pair.split("=", limit = 2)
|
||||
if (kv.size == 2 && kv[0] == name) return android.net.Uri.decode(kv[1])
|
||||
if (kv.size == 1 && kv[0] == name) return ""
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
val userJwtRaw = query("userJwt") ?: query("user_jwt") ?: ""
|
||||
val userJwt = runCatching {
|
||||
kotlinx.serialization.json.Json.parseToJsonElement(userJwtRaw).jsonObject
|
||||
}.getOrNull() ?: kotlinx.serialization.json.JsonObject(emptyMap())
|
||||
val token = userJwt["Token"]?.jsonPrimitive?.contentOrNull
|
||||
?: userJwt["token"]?.jsonPrimitive?.contentOrNull
|
||||
?: userJwt["AccessToken"]?.jsonPrimitive?.contentOrNull
|
||||
?: userJwt["accessToken"]?.jsonPrimitive?.contentOrNull
|
||||
?: if (raw.startsWith("{")) userJwt["jwt"]?.jsonPrimitive?.contentOrNull else null
|
||||
if (token.isNullOrBlank()) return null
|
||||
// 区域推断:回调显式 userRegion/us/INTL,或 userInfo.Region 非 CN 域
|
||||
val userRegion = query("userRegion")?.lowercase()
|
||||
val userInfoRegion = query("userInfo")?.let { infoRaw ->
|
||||
runCatching {
|
||||
val info = kotlinx.serialization.json.Json.parseToJsonElement(infoRaw).jsonObject
|
||||
info["Region"]?.jsonPrimitive?.contentOrNull
|
||||
?: info["region"]?.jsonPrimitive?.contentOrNull
|
||||
}.getOrNull()
|
||||
}
|
||||
val intl = userRegion?.let { it != "cn" } == true ||
|
||||
userInfoRegion?.let { !it.equals("cn", true) && !it.equals("china", true) } == true ||
|
||||
query("userRegion")?.equals("us", true) == true
|
||||
return token to (if (intl) "INTL" else "CN")
|
||||
}
|
||||
|
||||
fun updateTraeRegion(value: String) {
|
||||
_uiState.update { it.copy(traeRegion = value) }
|
||||
@ -311,7 +376,11 @@ class CredentialEditViewModel @Inject constructor(
|
||||
fun saveTraeCredential() {
|
||||
val type = serviceType ?: return
|
||||
val current = _uiState.value
|
||||
val jwt = current.traeJwt.trim()
|
||||
var jwt = current.traeJwt.trim()
|
||||
if (jwt.isBlank() && current.traeCallbackUrl.isNotBlank()) {
|
||||
val parsed = parseTraeCallback(current.traeCallbackUrl)
|
||||
if (parsed != null) jwt = parsed.first
|
||||
}
|
||||
if (jwt.isBlank()) {
|
||||
_uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) }
|
||||
return
|
||||
@ -978,6 +1047,8 @@ data class CredentialEditUiState(
|
||||
val triggerApiKey: String = "",
|
||||
/** Trae:Cloud-IDE-JWT + 区域 */
|
||||
val traeJwt: String = "",
|
||||
/** Trae:浏览器回调地址粘贴导入(http://127.0.0.1:18080/authorize?...) */
|
||||
val traeCallbackUrl: String = "",
|
||||
val traeRegion: String = "CN",
|
||||
/** Trae 签到设备 ID(官方客户端绑定 did;留空用内置默认) */
|
||||
val traeCheckinDeviceId: String = "",
|
||||
|
||||
@ -113,16 +113,7 @@ fun WebViewLoginScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = {
|
||||
val url = uiState.loginUrl
|
||||
if (url.isNotBlank()) {
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
TextButton(onClick = { viewModel.openExternalBrowser() }) {
|
||||
Text(stringResource(R.string.action_open_login_entry))
|
||||
}
|
||||
TextButton(onClick = {
|
||||
@ -142,6 +133,18 @@ fun WebViewLoginScreen(
|
||||
if (pageLoading) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
if (uiState.externalBrowserWaiting) {
|
||||
androidx.compose.material3.Surface(
|
||||
color = androidx.compose.material3.MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.webview_browser_waiting),
|
||||
style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
key(webViewGeneration) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
|
||||
@ -21,6 +21,7 @@ import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import com.rainy.token.R
|
||||
import com.rainy.token.data.debug.DebugLog
|
||||
import com.rainy.token.data.proxy.LoginCallbackServer
|
||||
import com.rainy.token.data.repository.WebViewSessionSaver
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.domain.model.Credential
|
||||
@ -149,7 +150,17 @@ class WebViewLoginViewModel @Inject constructor(
|
||||
// 登录 URL 与签到 claim 用同一个值。不能每次随机(会话绑定不一致会被风控拒),
|
||||
// 也不可用 userJwt.ClientID(固定客户端标识,所有用户相同,必被按设备限流)。
|
||||
val deviceId = traeDeviceId()
|
||||
val callbackUrl = "http://127.0.0.1:18080/authorize"
|
||||
val callbackUrl = buildTraeAuthUrl(LOGIN_WEBVIEW_CALLBACK_PORT)
|
||||
_uiState.update { it.copy(loginUrl = callbackUrl) }
|
||||
return
|
||||
}
|
||||
|
||||
/** 构建 Trae 授权 URL;WebView 走 18080,外部浏览器走 18081(本地回调服务器)。 */
|
||||
private fun buildTraeAuthUrl(callbackPort: Int): String {
|
||||
val traceId = randomHex(16)
|
||||
val machineId = randomHex(32)
|
||||
val deviceId = traeDeviceId()
|
||||
val callbackUrl = "http://127.0.0.1:$callbackPort/authorize"
|
||||
val authRoot = if (region == "INTL") "https://www.trae.ai/authorization" else "https://www.trae.cn/authorization"
|
||||
val authUrl = buildString {
|
||||
append(authRoot).append("?")
|
||||
@ -165,8 +176,8 @@ class WebViewLoginViewModel @Inject constructor(
|
||||
append("x_os_version=1.0&x_app_version=0.1.43&")
|
||||
append("x_app_type=stable&plugin_version=2.3.62834")
|
||||
}
|
||||
DebugLog.i("TraeLogin", "bindTrae: 授权URL已生成 len=${authUrl.length} deviceId持久化=${deviceId.take(6)}…")
|
||||
_uiState.update { it.copy(loginUrl = authUrl) }
|
||||
DebugLog.i("TraeLogin", "buildTraeAuthUrl: port=$callbackPort len=${authUrl.length} deviceId持久化=${deviceId.take(6)}…")
|
||||
return authUrl
|
||||
}
|
||||
|
||||
/** Trae 签到用的持久化设备 ID:首次生成 19 位数字并保存,此后跨登录复用。 */
|
||||
@ -504,6 +515,67 @@ class WebViewLoginViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「在浏览器中打开登录入口」:Trae 起本地回调服务器,登录完自动回传凭证;
|
||||
* WorkBuddy 直接打开浏览器并继续原有轮询(官方登录本身就在浏览器完成)。
|
||||
*/
|
||||
fun openExternalBrowser() {
|
||||
val service = _uiState.value.service ?: return
|
||||
val currentUrl = _uiState.value.loginUrl
|
||||
when (service) {
|
||||
ServiceType.TRAE -> {
|
||||
if (!LoginCallbackServer.start(LOGIN_EXTERNAL_CALLBACK_PORT)) {
|
||||
_uiState.update {
|
||||
it.copy(error = UiText.Resource(R.string.error_login_callback_server))
|
||||
}
|
||||
return
|
||||
}
|
||||
val authUrl = buildTraeAuthUrl(LOGIN_EXTERNAL_CALLBACK_PORT)
|
||||
_uiState.update {
|
||||
it.copy(loginUrl = authUrl, externalBrowserWaiting = true, error = null)
|
||||
}
|
||||
launchExternalBrowser(authUrl)
|
||||
viewModelScope.launch {
|
||||
val callback = LoginCallbackServer.await(LOGIN_EXTERNAL_TIMEOUT_MS)
|
||||
if (callback != null) {
|
||||
onTraeCallback(callback)
|
||||
} else if (!_uiState.value.loginSucceeded) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
error = UiText.Resource(R.string.error_login_timeout),
|
||||
externalBrowserWaiting = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ServiceType.WORKBUDDY -> {
|
||||
if (currentUrl.isNotBlank()) {
|
||||
_uiState.update {
|
||||
it.copy(loginUrl = currentUrl, externalBrowserWaiting = true, error = null)
|
||||
}
|
||||
launchExternalBrowser(currentUrl)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (currentUrl.isNotBlank()) {
|
||||
launchExternalBrowser(currentUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchExternalBrowser(url: String) {
|
||||
runCatching {
|
||||
val ctx = com.rainy.token.RainyTokenApplication.appContext
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(url)
|
||||
).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
ctx.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除 WebView 登录缓存(Cookie/Storage),用于更换账号登录。 */
|
||||
fun clearWebViewSession() {
|
||||
runCatching {
|
||||
@ -668,6 +740,10 @@ private const val SUB2_NONCE_KEY = "nonce"
|
||||
/** OpenCode dashboard URL 中的 workspaceId:`/workspace/{id}/go`。 */
|
||||
private val OPENCODE_WORKSPACE_REGEX = Regex("""/workspace/([^/?#]+)/go""")
|
||||
|
||||
private const val LOGIN_WEBVIEW_CALLBACK_PORT = 18080
|
||||
private const val LOGIN_EXTERNAL_CALLBACK_PORT = 18081
|
||||
private const val LOGIN_EXTERNAL_TIMEOUT_MS = 20 * 60 * 1000L
|
||||
|
||||
internal fun buildProbeScript(nonce: String): String = """
|
||||
(function () {
|
||||
try {
|
||||
@ -698,5 +774,7 @@ data class WebViewLoginUiState(
|
||||
val pendingManualConfirm: Boolean = false,
|
||||
val error: UiText? = null,
|
||||
/** Trae/WorkBuddy 登录实际写入的账号 ID(新建账号模式时用于回填信用编辑页)。 */
|
||||
val loginAccountId: String? = null
|
||||
val loginAccountId: String? = null,
|
||||
/** 已在手机浏览器打开登录页,等待回调/轮询自动带回凭证。 */
|
||||
val externalBrowserWaiting: Boolean = false
|
||||
)
|
||||
|
||||
@ -424,6 +424,12 @@
|
||||
<string name="error_cookie_not_found">未抓到 Cookie,请确认已登录</string>
|
||||
<string name="error_login_url_missing">未配置登录 URL</string>
|
||||
<string name="error_login_token_missing">登录成功但未获取到令牌,请复制回调地址里的 userJwt 后手动粘贴</string>
|
||||
<string name="field_trae_callback_url">Trae 登录回调链接</string>
|
||||
<string name="field_trae_callback_hint">在浏览器登录完成后,把地址栏整条链接粘到这里(http://127.0.0.1:18080/authorize?...)</string>
|
||||
<string name="action_parse_callback">解析并填入 JWT</string>
|
||||
<string name="error_trae_callback_invalid">无法解析这条回调链接,请完整复制地址栏内容</string>
|
||||
<string name="error_login_callback_server">本地回调服务启动失败,请改用应用内登录</string>
|
||||
<string name="webview_browser_waiting">已在浏览器打开,登录完成后会自动回到雨晴Token</string>
|
||||
<string name="error_login_timeout">登录超时,请重试</string>
|
||||
<string name="error_oauth_url_prefix">URL 应以 %1$s 开头</string>
|
||||
<string name="error_oauth_auth_failed">授权失败: %1$s</string>
|
||||
|
||||
@ -424,6 +424,12 @@
|
||||
<string name="error_cookie_not_found">未抓到 Cookie,請確認已登入</string>
|
||||
<string name="error_login_url_missing">未配置登入 URL</string>
|
||||
<string name="error_login_token_missing">登入成功但未取得令牌,請複製回呼位址中的 userJwt 後手動貼上</string>
|
||||
<string name="field_trae_callback_url">Trae 登入回呼連結</string>
|
||||
<string name="field_trae_callback_hint">在瀏覽器登入完成後,把網址列整條連結貼到這裡(http://127.0.0.1:18080/authorize?...)</string>
|
||||
<string name="action_parse_callback">解析並填入 JWT</string>
|
||||
<string name="error_trae_callback_invalid">無法解析這條回呼連結,請完整複製網址列內容</string>
|
||||
<string name="error_login_callback_server">本機回呼服務啟動失敗,請改用應用內登入</string>
|
||||
<string name="webview_browser_waiting">已在瀏覽器開啟,登入完成後會自動回到雨晴Token</string>
|
||||
<string name="error_login_timeout">登入逾時,請重試</string>
|
||||
<string name="error_oauth_url_prefix">URL 應以 %1$s 開頭</string>
|
||||
<string name="error_oauth_auth_failed">授權失敗: %1$s</string>
|
||||
|
||||
@ -427,6 +427,12 @@
|
||||
<string name="error_cookie_not_found">No cookie captured, please make sure you\'re logged in</string>
|
||||
<string name="error_login_url_missing">No login URL configured</string>
|
||||
<string name="error_login_token_missing">Login succeeded but no token was received. Copy userJwt from the callback URL and paste it manually.</string>
|
||||
<string name="field_trae_callback_url">Trae login callback URL</string>
|
||||
<string name="field_trae_callback_hint">Paste the whole callback URL after logging in the browser (http://127.0.0.1:18080/authorize?...).</string>
|
||||
<string name="action_parse_callback">Parse and fill JWT</string>
|
||||
<string name="error_trae_callback_invalid">Could not parse this callback URL. Please copy the address bar URL completely.</string>
|
||||
<string name="error_login_callback_server">Unable to start local callback server. Please use in-app login instead.</string>
|
||||
<string name="webview_browser_waiting">Opened in browser. After login it will return to RainyToken automatically.</string>
|
||||
<string name="error_login_timeout">Login timed out, please retry</string>
|
||||
<string name="error_oauth_url_prefix">URL should start with %1$s</string>
|
||||
<string name="error_oauth_auth_failed">Authorization failed: %1$s</string>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user