Flutterアプリケーション開発概論

【権限変更】ownerだけがメンバーの権限を変更できるようにする

42LINE風チームタスク管理アプリを作りながら、ログイン・データベース・権限管理を学ぶ
FlutteriOSAndroidMacOSWindows基礎から学ぶ開発アプリ開発

このページでやること

このページでは、既存メンバーの権限を変更できるようにします。

ただし、権限変更ができるのは owner だけにします。

owner  → 権限変更できる
admin  → 権限変更できない
member → 権限変更できない
viewer → 権限変更できない

変更できる権限は、この3つです。

admin
member
viewer

owner は最上位権限なので、この画面では選ばせません。


今日のゴール

メンバー一覧画面で、メンバーをタップすると、権限変更画面を開けるようにします。

メンバー一覧
↓
メンバーをタップ
↓
自分がownerか確認
↓
ownerなら権限変更画面を表示
↓
admin / member / viewer を選ぶ
↓
Firestoreを更新

保存場所はここです。

teams/{teamId}/members/{uid}

npmや環境変数は必要?

このページでは、npmは使いません。

環境変数も設定しません。

FlutterとFirestoreの既存設定だけで進めます。

必要なimportはこちらです。

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';

Step 1:main.dartを開く

次のファイルを開きます。

lib/main.dart

ターミナルから開く場合はこちらです。

code lib/main.dart

Step 2:MemberListPageを探す

main.dart の中で、次を検索します。

class MemberListPage

VS Codeなら、次で検索できます。

command + F

今回は、_MemberListPageState の中に処理を追加します。


Step 3:権限変更用の変数を作る

_MemberListPageState の中に、次を追加します。

String selectedEditRole = 'member';

完成イメージです。

class _MemberListPageState extends State<MemberListPage> {
  final emailController = TextEditingController();

  String selectedMemberRole = 'member';
  String selectedEditRole = 'member';

  bool isAdding = false;
  String? errorText;

  @override
  void dispose() {
    emailController.dispose();
    super.dispose();
  }
}

selectedEditRole は、権限変更画面で選ばれている権限を覚える変数です。


Step 4:自分の権限を取得する関数を作る

まず、自分が owner かどうかを確認する必要があります。

_MemberListPageState の中に、次の関数を追加します。

Future<TeamRole> getMyRole() async {
  final user = FirebaseAuth.instance.currentUser;

  if (user == null) {
    return TeamRole.viewer;
  }

  final memberDoc = await FirebaseFirestore.instance
      .collection('teams')
      .doc(widget.teamId)
      .collection('members')
      .doc(user.uid)
      .get();

  final data = memberDoc.data();
  final roleText = data?['role']?.toString();

  return teamRoleFromString(roleText);
}

この関数は、Firestoreのここを見に行きます。

teams/{teamId}/members/{自分のuid}

Step 5:ownerかどうかを判定する関数を作る

_MemberListPageState の中に、次の関数を追加します。

bool canChangeMemberRole(TeamRole role) {
  return role == TeamRole.owner;
}

意味はこうです。

ownerならtrue
owner以外ならfalse

このページでは、権限変更は owner だけにします。


Step 6:権限がないときのメッセージを作る

_MemberListPageState の中に、次の関数を追加します。

void showNoPermissionMessage() {
  ScaffoldMessenger.of(context).showSnackBar(
    const SnackBar(
      content: Text('権限を変更できるのはオーナーのみです。'),
    ),
  );
}

SnackBar は、画面下に出る短いメッセージです。


Step 7:MemberCardをタップできるようにする

今の MemberCard は、表示だけになっている場合があります。

MemberCardonTap を追加します。

class MemberCard extends StatelessWidget {
  const MemberCard({
    super.key,
    required this.displayName,
    required this.email,
    required this.role,
    required this.onTap,
  });

  final String displayName;
  final String email;
  final TeamRole role;
  final VoidCallback onTap;

追加したのは、この2つです。

required this.onTap,
final VoidCallback onTap;

Step 8:MemberCardをInkWellで包む

MemberCardbuild() を、タップできる形にします。

@override
Widget build(BuildContext context) {
  return AppCard(
    onTap: onTap,
    child: Row(
      children: [
        CircleAvatar(
          backgroundColor: AppColors.lineGreen,
          child: Text(
            displayName.isNotEmpty ? displayName.substring(0, 1) : '?',
            style: const TextStyle(
              color: Colors.white,
              fontWeight: FontWeight.w900,
            ),
          ),
        ),
        const SizedBox(width: 12),
        Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                displayName,
                style: const TextStyle(
                  color: AppColors.text,
                  fontSize: 16,
                  fontWeight: FontWeight.w900,
                ),
              ),
              if (email.isNotEmpty) ...[
                const SizedBox(height: 4),
                Text(
                  email,
                  style: const TextStyle(
                    color: AppColors.subText,
                    fontSize: 13,
                    fontWeight: FontWeight.w600,
                  ),
                ),
              ],
            ],
          ),
        ),
        _Label(text: teamRoleLabel(role)),
      ],
    ),
  );
}

ここでは、すでに作ってある AppCard(onTap: ...) を使います。


Step 9:MemberCard呼び出しを修正する

MemberListPage の中で、MemberCard を返している部分を探します。

今は、次のようになっているはずです。

return MemberCard(
  displayName: displayName,
  email: email,
  role: role,
);

これを、次のように変更します。

return MemberCard(
  displayName: displayName,
  email: email,
  role: role,
  onTap: () async {
    final myRole = await getMyRole();

    if (!canChangeMemberRole(myRole)) {
      showNoPermissionMessage();
      return;
    }

    if (role == TeamRole.owner) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text('オーナーの権限はこの画面では変更できません。'),
        ),
      );
      return;
    }

    showEditMemberRoleSheet(
      targetUid: doc.id,
      displayName: displayName,
      email: email,
      currentRole: role,
    );
  },
);

これで、メンバーをタップしたときに権限チェックが入ります。


Step 10:権限変更ボトムシートを作る

_MemberListPageState の中に、次の関数を追加します。

Future<void> showEditMemberRoleSheet({
  required String targetUid,
  required String displayName,
  required String email,
  required TeamRole currentRole,
}) async {
  selectedEditRole = currentRole.name;
  errorText = null;

  await showModalBottomSheet<void>(
    context: context,
    isScrollControlled: true,
    backgroundColor: Colors.white,
    shape: const RoundedRectangleBorder(
      borderRadius: BorderRadius.vertical(
        top: Radius.circular(20),
      ),
    ),
    builder: (context) {
      return StatefulBuilder(
        builder: (context, setSheetState) {
          return Padding(
            padding: EdgeInsets.only(
              left: 20,
              right: 20,
              top: 20,
              bottom: MediaQuery.of(context).viewInsets.bottom + 20,
            ),
            child: SingleChildScrollView(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  Container(
                    width: 44,
                    height: 5,
                    margin: const EdgeInsets.only(bottom: 18),
                    decoration: BoxDecoration(
                      color: AppColors.border,
                      borderRadius: BorderRadius.circular(999),
                    ),
                  ),
                  const Text(
                    '権限を変更',
                    style: TextStyle(
                      fontSize: 20,
                      fontWeight: FontWeight.w900,
                      color: AppColors.text,
                    ),
                  ),
                  const SizedBox(height: 12),
                  AppCard(
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          displayName,
                          style: const TextStyle(
                            color: AppColors.text,
                            fontSize: 16,
                            fontWeight: FontWeight.w900,
                          ),
                        ),
                        if (email.isNotEmpty) ...[
                          const SizedBox(height: 4),
                          Text(
                            email,
                            style: const TextStyle(
                              color: AppColors.subText,
                              fontWeight: FontWeight.w600,
                            ),
                          ),
                        ],
                      ],
                    ),
                  ),
                  const SizedBox(height: 16),
                  const Text(
                    '新しい権限',
                    style: TextStyle(
                      color: AppColors.text,
                      fontWeight: FontWeight.w900,
                    ),
                  ),
                  const SizedBox(height: 8),
                  Wrap(
                    spacing: 8,
                    runSpacing: 8,
                    children: [
                      ChoiceChip(
                        label: const Text('管理者'),
                        selected: selectedEditRole == 'admin',
                        onSelected: (_) {
                          setSheetState(() {
                            selectedEditRole = 'admin';
                          });
                        },
                      ),
                      ChoiceChip(
                        label: const Text('メンバー'),
                        selected: selectedEditRole == 'member',
                        onSelected: (_) {
                          setSheetState(() {
                            selectedEditRole = 'member';
                          });
                        },
                      ),
                      ChoiceChip(
                        label: const Text('閲覧者'),
                        selected: selectedEditRole == 'viewer',
                        onSelected: (_) {
                          setSheetState(() {
                            selectedEditRole = 'viewer';
                          });
                        },
                      ),
                    ],
                  ),
                  const SizedBox(height: 10),
                  Container(
                    decoration: BoxDecoration(
                      color: AppColors.bg,
                      borderRadius: BorderRadius.circular(12),
                      border: Border.all(color: AppColors.border),
                    ),
                    padding: const EdgeInsets.all(12),
                    child: Text(
                      roleDescription(selectedEditRole),
                      style: const TextStyle(
                        color: AppColors.subText,
                        height: 1.5,
                        fontWeight: FontWeight.w700,
                      ),
                    ),
                  ),
                  if (errorText != null) ...[
                    const SizedBox(height: 12),
                    ErrorBox(message: errorText!),
                  ],
                  const SizedBox(height: 16),
                  FilledButton(
                    onPressed: () async {
                      await updateMemberRole(
                        setSheetState: setSheetState,
                        targetUid: targetUid,
                      );
                    },
                    child: const Text('変更する'),
                  ),
                ],
              ),
            ),
          );
        },
      );
    },
  );
}

これで、権限変更用のボトムシートができます。


Step 11:権限説明 roleDescription を確認する

前のページで作った roleDescription() があるか確認します。

String roleDescription(String role) {
  switch (role) {
    case 'admin':
      return 'タスク作成・編集・削除、メンバー管理ができます。';
    case 'viewer':
      return 'タスクやメンバーを確認できます。作成・編集・削除はできません。';
    case 'member':
    default:
      return 'タスクの作成・編集ができます。削除やメンバー管理はできません。';
  }
}

まだない場合は、_MemberListPageState の中に追加してください。


Step 12:Firestoreを更新する処理を作る

_MemberListPageState の中に、次の関数を追加します。

Future<void> updateMemberRole({
  required StateSetter setSheetState,
  required String targetUid,
}) async {
  try {
    await FirebaseFirestore.instance
        .collection('teams')
        .doc(widget.teamId)
        .collection('members')
        .doc(targetUid)
        .update({
      'role': selectedEditRole,
      'updatedAt': FieldValue.serverTimestamp(),
    });

    if (mounted) {
      Navigator.of(context).pop();
    }
  } on FirebaseException catch (e) {
    setSheetState(() {
      errorText = e.message ?? '権限変更に失敗しました。';
    });
  } catch (_) {
    setSheetState(() {
      errorText = '権限変更に失敗しました。';
    });
  }
}

これで、Firestore上の role を更新できます。


Step 13:更新する場所を確認する

今回、更新する場所はここです。

teams/{teamId}/members/{uid}

更新する項目は、この2つです。

role
updatedAt

コードではここです。

.update({
  'role': selectedEditRole,
  'updatedAt': FieldValue.serverTimestamp(),
});

Step 14:なぜownerは変更させないのか

owner は、チームの最上位権限です。

そのため、この画面では変更させません。

owner → 変更不可
admin → 変更可能
member → 変更可能
viewer → 変更可能

もし owner を簡単に変更できると、チーム管理者がいなくなったり、意図せず権限を失ったりする危険があります。


Step 15:保存する

main.dart を保存します。

Macの場合:

command + S

Windowsの場合:

Ctrl + S

Step 16:実行する

ターミナルで実行します。

flutter run

すでに起動している場合は、ターミナルで r を押します。

r

うまく反映されない場合は、R を押します。

R

Step 17:ownerで権限変更を確認する

自分の roleowner の状態で確認します。

ログイン
↓
トーク一覧
↓
チームをタップ
↓
右上のメンバーアイコン
↓
メンバーをタップ

権限変更画面が開けばOKです。

管理者
メンバー
閲覧者

たとえば、memberviewer に変更してみます。

Firestoreと画面表示が変われば成功です。


Step 18:adminで変更できないことを確認する

Firestoreで自分の roleadmin に変えます。

teams
↓
作成したteamId
↓
members
↓
自分のuid
↓
role: admin

アプリに戻って、メンバーをタップします。

次のメッセージが出ればOKです。

権限を変更できるのはオーナーのみです。

Step 19:member / viewer でも確認する

同じように、自分の role を変更して確認します。

role: member

または、

role: viewer

どちらも、権限変更できなければOKです。


よくあるエラーと直し方

エラー原因直し方
selectedEditRole isn't defined変数がないString selectedEditRole = 'member'; を追加
getMyRole isn't defined自分の権限取得関数がないgetMyRole() を追加
canChangeMemberRole isn't defined判定関数がないcanChangeMemberRole() を追加
showEditMemberRoleSheet isn't definedボトムシート関数がないshowEditMemberRoleSheet() を追加
updateMemberRole isn't defined更新処理がないupdateMemberRole() を追加
roleDescription isn't defined説明関数がないroleDescription() を追加
タップできないMemberCardにonTapがないonTap を追加
ownerなのに変更できないFirestoreのroleがownerでないmembers/{uid} を確認

permission-denied が出たとき

開発中だけ、Firestore Rulesを次のようにして確認できます。

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if true;
    }
  }
}

これは学習用です。

本番公開では使わないでください。

本番では、Firestore Security Rules側でも owner だけが権限変更できるようにします。


注意:Flutter側だけでは本番の安全対策にならない

このページでは、Flutter側で owner だけ操作できるようにしています。

ただし、本番では、Firestore Rules側にも必ず制限を書きます。

Flutter側
↓
画面上の操作を制御する

Firestore Rules側
↓
本当にデータを守る

最終的な安全対策は、Firestore Rulesで行います。


最短作業まとめ

読むのが大変な人は、ここだけ見てください。

1. ownerだけ許可する

bool canChangeMemberRole(TeamRole role) {
  return role == TeamRole.owner;
}

2. MemberCardをタップ可能にする

return MemberCard(
  displayName: displayName,
  email: email,
  role: role,
  onTap: () async {
    final myRole = await getMyRole();

    if (!canChangeMemberRole(myRole)) {
      showNoPermissionMessage();
      return;
    }

    if (role == TeamRole.owner) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text('オーナーの権限はこの画面では変更できません。'),
        ),
      );
      return;
    }

    showEditMemberRoleSheet(
      targetUid: doc.id,
      displayName: displayName,
      email: email,
      currentRole: role,
    );
  },
);

3. Firestoreのroleを更新する

await FirebaseFirestore.instance
    .collection('teams')
    .doc(widget.teamId)
    .collection('members')
    .doc(targetUid)
    .update({
  'role': selectedEditRole,
  'updatedAt': FieldValue.serverTimestamp(),
});

チェックリスト

□ main.dartを開いた
□ MemberListPageを探した
□ selectedEditRoleを作った
□ getMyRole()を作った
□ canChangeMemberRole()を作った
□ showNoPermissionMessage()を作った
□ MemberCardにonTapを追加した
□ MemberCard呼び出しで権限チェックした
□ owner以外は変更できないようにした
□ owner本人の権限は変更不可にした
□ showEditMemberRoleSheet()を作った
□ ChoiceChipでadmin/member/viewerを選べるようにした
□ updateMemberRole()を作った
□ Firestoreのroleを更新した
□ 保存した
□ flutter runで確認した

ミニ確認問題

Q1. メンバーの権限を変更できるのは誰ですか?

回答

owner だけです。


Q2. adminはメンバーの権限を変更できますか?

回答

この教材の設計では、できません。

権限変更は owner だけにします。


Q3. ownerの権限はこの画面で変更できますか?

回答

できません。

owner は最上位権限なので、この画面では変更させません。


Q4. 権限変更ではFirestoreのどこを更新しますか?

回答

次の場所を更新します。

teams/{teamId}/members/{uid}

Q5. このページでnpmや環境変数は必要ですか?

回答

必要ありません。

FlutterとFirestoreの既存設定だけで進めます。


このページのまとめ

  • メンバー権限変更は owner だけにする。
  • adminmemberviewer は権限変更できない。
  • owner 自身の権限は、この画面では変更させない。
  • MemberCard をタップできるようにする。
  • 権限変更画面はボトムシートで作る。
  • ChoiceChipadminmemberviewer を選ぶ。
  • Firestoreの teams/{teamId}/members/{uid}role を更新する。
  • 本番ではFirestore Security Rules側にも必ず制限を書く。
  • このページではnpmや環境変数は不要。

次のページでやること

次のページでは、メンバー削除を作ります。

owner だけが、チームからメンバーを外せるようにします。

教材トップへ戻る