# 登录与授权约定入口

`src/auth` 用于登录、隐私授权和自定义登录页。没有 `src/auth` 入口时，旧目录 `src/mcp-ui` 继续作为兼容 fallback。该目录由构建工具按约定扫描，不需要写到 `src/app.config.ts` 的 `pages` / `widgets` 中。

---

## 入口类型

| 入口 | 文件路径 | 是否手写 | 用途 |
|------|----------|----------|------|
| 隐私协议回调 | `src/auth/privacy.ts` | 按需手写 | 处理同意或拒绝隐私协议 |
| 登录回调 | `src/auth/login.ts` | 按需手写 | 处理手机号授权登录或拒绝登录 |
| 登录页面 | `src/auth/login-page/index.tsx` | 按需手写 | 展示自定义登录页 |
| 登录/隐私卡片 | `mcp-privacy-login-card` | 框架生成 | 根据 `privacy.ts` / `login.ts` 自动挂接回调 |

`mcp-privacy-login-card` 是内置虚拟 Widget，构建工具会自动注入固定 metadata。不要在 `src/widgets` 中创建同名目录，
也不要手动注册到 `app.config.ts`。

---

## privacy.ts

`privacy.ts` 只导出隐私协议回调，不定义 UI。

```ts
import { definePrivacyApi, type PrivacyAgreeResult } from '@doubao-dev/framework';

export default definePrivacyApi({
  async onAgreePrivacy(res: PrivacyAgreeResult) {
    console.log('onAgreePrivacy', res);
  },

  async onRefusePrivacy() {
    console.log('onRefusePrivacy');
  }
});
```

---

## login.ts

`login.ts` 只导出登录回调，不定义 UI。内置登录卡完成手机号授权后，会把手机号授权结果传给 `onLogin`。

最小登录闭环：

1. 从 `LoginResult` 读取手机号授权 `code`。
2. 调用 `login()` 获取豆包智能服务登录 `code`。
3. 把两个 code 发给业务后端换取业务登录凭证。
4. 按需保存业务 session。
5. 调用 `postLoginResult()` 把登录结果回传给宿主。

示例中的后端地址是占位 URL，实际项目需要替换成自己的 HTTPS 登录接口。

```ts
import { defineLoginApi, type LoginResult } from '@doubao-dev/framework';
import { login, postLoginResult, request, setStorage } from '@doubao-dev/framework/api';

interface AuthResponse {
  loginCode: string;
  sessionKey: string;
}

function isAuthResponse(data: unknown): data is AuthResponse {
  return (
    typeof data === 'object' &&
    data !== null &&
    'loginCode' in data &&
    'sessionKey' in data &&
    typeof data.loginCode === 'string' &&
    typeof data.sessionKey === 'string'
  );
}

async function exchangeLoginCode(params: { appId: string; loginCode: string; phoneCode: string }) {
  const response = await request({
    url: 'https://example.com/api/doubao-login',
    method: 'POST',
    header: {
      'content-type': 'application/json'
    },
    data: params
  });

  if (response.statusCode < 200 || response.statusCode >= 300 || !isAuthResponse(response.data)) {
    throw new Error('登录响应无效');
  }

  return response.data;
}

export default defineLoginApi({
  async onLogin(res: LoginResult) {
    if (!res.code) {
      await postLoginResult({ result: false });
      return;
    }

    try {
      const loginResult = await login({ timeout: 30000 });
      const auth = await exchangeLoginCode({
        appId: 'your-app-id',
        loginCode: loginResult.code,
        phoneCode: res.code
      });

      await setStorage({
        key: 'session_key',
        data: auth.sessionKey
      });

      await postLoginResult({
        result: true,
        code: auth.loginCode
      });
    } catch (error) {
      console.error('onLogin failed', error);
      await postLoginResult({ result: false });
    }
  },

  async onRefuseLogin() {
    await postLoginResult({ result: false });
  }
});
```

---

## login-page

自定义登录页放在 `src/auth/login-page/index.tsx`，直接默认导出组件函数。
旧路径 `src/auth/mcp-login-page/index.tsx` 继续兼容；没有 `src/auth` 入口时，也会继续兼容 `src/mcp-ui/mcp-login-page/index.tsx`。
构建工具会把它作为特殊 Page 处理，metadata 仍使用内置 `id: 'mcp-login-page'` 和 `path: '/pages/mcp-login-page/index'`。
已有 `defineLoginPage()` 对象入口无需迁移；新登录页不要使用其他目录名。

```tsx
import {
  useViewData,
  type LoginResult,
  useDestroy,
  useEffect,
  useState
} from '@doubao-dev/framework';
import {
  login,
  navigateBack,
  postLoginResult,
  request,
  setStorage,
  showToast
} from '@doubao-dev/framework/api';
import './index.scss';

interface McpLoginPageData {
  title?: string;
  description?: string;
}

interface AuthResponse {
  loginCode: string;
  sessionKey: string;
}

function isAuthResponse(data: unknown): data is AuthResponse {
  return (
    typeof data === 'object' &&
    data !== null &&
    'loginCode' in data &&
    'sessionKey' in data &&
    typeof data.loginCode === 'string' &&
    typeof data.sessionKey === 'string'
  );
}

let loginResultPosted = false;

async function postLoginFailure() {
  if (loginResultPosted) {
    return;
  }

  await postLoginResult({ result: false });
  loginResultPosted = true;
}

async function exchangeLoginCode(params: { appId: string; loginCode: string; phoneCode: string }) {
  const response = await request({
    url: 'https://example.com/api/doubao-login',
    method: 'POST',
    header: {
      'content-type': 'application/json'
    },
    data: params
  });

  if (response.statusCode < 200 || response.statusCode >= 300 || !isAuthResponse(response.data)) {
    throw new Error('登录响应无效');
  }

  return response.data;
}

export default function McpLoginPage() {
  const viewData = useViewData<McpLoginPageData>();
  const [submitting, setSubmitting] = useState(false);

  useEffect(() => {
    loginResultPosted = false;
  }, []);

  useDestroy(() => {
    void postLoginFailure();
  });

  const handlePhoneLogin = async (res?: LoginResult) => {
    if (submitting) {
      return;
    }

    if (!res?.code) {
      showToast({ message: res?.message || '未完成手机号授权', type: 'warning' });
      return;
    }

    try {
      setSubmitting(true);
      const loginResult = await login({ timeout: 30000 });
      const auth = await exchangeLoginCode({
        appId: 'your-app-id',
        loginCode: loginResult.code,
        phoneCode: res.code
      });

      await setStorage({
        key: 'session_key',
        data: auth.sessionKey
      });

      await postLoginResult({
        result: true,
        code: auth.loginCode
      });
      loginResultPosted = true;
      await navigateBack();
    } catch (error) {
      console.error('mcp login failed', error);
      showToast({ message: '登录失败，请稍后重试', type: 'error' });
    } finally {
      setSubmitting(false);
    }
  };

  const handleCancel = async () => {
    await postLoginFailure();
    await navigateBack();
  };

  return (
    <view className="mcp-login-page">
      <text className="mcp-login-page__title">{viewData.title || '登录'}</text>
      <text className="mcp-login-page__description">{viewData.description || '请完成登录后继续使用'}</text>
      <button
        text={submitting ? '登录中...' : '手机号一键登录'}
        type="primary"
        openType="getPhoneNumber"
        loading={submitting}
        disabled={submitting}
        onGetPhoneNumber={(res) => {
          void handlePhoneLogin(res);
        }}
      />
      <button text="暂不登录" disabled={submitting} onClick={handleCancel} />
    </view>
  );
}
```
